mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
先提交上去
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "secscore"
|
||||
version = "1.0.0"
|
||||
description = "SecScore - Security Score Application"
|
||||
authors = ["SecScore Team"]
|
||||
edition = "2021"
|
||||
rust-version = "1.70"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png", "protocol-asset"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] }
|
||||
sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
once_cell = "1"
|
||||
parking_lot = { version = "0.12", features = ["send_guard"] }
|
||||
dirs = "6.0.0"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for SecScore application",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:default",
|
||||
"core:app:default",
|
||||
"core:tray:default",
|
||||
"core:tray:allow-new",
|
||||
"core:tray:allow-set-icon",
|
||||
"core:tray:allow-set-menu",
|
||||
"core:menu:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,87 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterUrlProtocolResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub registered: Option<bool>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_url_protocol(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<RegisterUrlProtocolResult>, String> {
|
||||
let protocol = "secscore";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
||||
|
||||
let exe_path_str = exe_path.to_string_lossy();
|
||||
|
||||
let reg_command = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}" /ve /d "URL:SecScore Protocol" /f"#,
|
||||
protocol
|
||||
);
|
||||
|
||||
let reg_command2 = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}\DefaultIcon" /ve /d "{},1" /f"#,
|
||||
protocol, exe_path_str
|
||||
);
|
||||
|
||||
let reg_command3 = format!(
|
||||
r#"reg add "HKCU\Software\Classes\{}\shell\open\command" /ve /d "\"{}\" \"%%1\"" /f"#,
|
||||
protocol, exe_path_str
|
||||
);
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", ®_command])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", ®_command2])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", ®_command3])
|
||||
.output();
|
||||
|
||||
let _ = app;
|
||||
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(true),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = app;
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(false),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = app;
|
||||
return Ok(IpcResponse::success(RegisterUrlProtocolResult {
|
||||
registered: Some(false),
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(IpcResponse::failure("URL protocol registration is not supported on this platform"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::{
|
||||
auth::{AuthService, SetPasswordsPayload},
|
||||
SecurityService,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStatusResponse {
|
||||
pub permission: String,
|
||||
pub has_admin_password: bool,
|
||||
pub has_points_password: bool,
|
||||
pub has_recovery_string: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetPasswordsResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recovery_string: Option<String>,
|
||||
}
|
||||
|
||||
fn get_iv_hex() -> String {
|
||||
SecurityService::generate_iv_hex()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_get_status(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<AuthStatusResponse>, String> {
|
||||
let state_guard = state.read();
|
||||
let settings = state_guard.settings.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
let status = AuthService::get_status(&settings, sender_id, &mut permissions);
|
||||
|
||||
let response = AuthStatusResponse {
|
||||
permission: status.permission,
|
||||
has_admin_password: status.has_admin_password,
|
||||
has_points_password: status.has_points_password,
|
||||
has_recovery_string: status.has_recovery_string,
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(response))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_login(
|
||||
password: String,
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<LoginResponse>, String> {
|
||||
let sender = sender_id.ok_or("Invalid sender")?;
|
||||
|
||||
let iv_hex = get_iv_hex();
|
||||
|
||||
let result = {
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
let security = state_guard.security.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
AuthService::login(
|
||||
&mut settings,
|
||||
&security,
|
||||
&mut permissions,
|
||||
sender,
|
||||
&password,
|
||||
&iv_hex,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if result.success {
|
||||
Ok(IpcResponse::success(LoginResponse {
|
||||
permission: result.permission.unwrap_or_else(|| "view".to_string()),
|
||||
}))
|
||||
} else {
|
||||
Ok(IpcResponse::failure_with_type(
|
||||
&result.message.unwrap_or_else(|| "Login failed".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_logout(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<LoginResponse>, String> {
|
||||
let default_permission = {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
if let Some(id) = sender_id {
|
||||
let level = AuthService::logout(&mut permissions, id);
|
||||
level.as_str().to_string()
|
||||
} else {
|
||||
permissions.get_default_permission().as_str().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(LoginResponse {
|
||||
permission: default_permission,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_set_passwords(
|
||||
admin_password: Option<String>,
|
||||
points_password: Option<String>,
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SetPasswordsResponse>, String> {
|
||||
let sender = sender_id.ok_or("Invalid sender")?;
|
||||
|
||||
let iv_hex = get_iv_hex();
|
||||
|
||||
let payload = SetPasswordsPayload {
|
||||
admin_password,
|
||||
points_password,
|
||||
};
|
||||
|
||||
let result = {
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
let security = state_guard.security.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
AuthService::set_passwords(
|
||||
&mut settings,
|
||||
&security,
|
||||
&mut permissions,
|
||||
sender,
|
||||
payload,
|
||||
&iv_hex,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if result.success {
|
||||
Ok(IpcResponse::success(SetPasswordsResponse {
|
||||
recovery_string: result.recovery_string,
|
||||
}))
|
||||
} else {
|
||||
Ok(IpcResponse::failure_with_type(
|
||||
&result
|
||||
.message
|
||||
.unwrap_or_else(|| "Failed to set passwords".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_generate_recovery(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SetPasswordsResponse>, String> {
|
||||
let sender = sender_id.ok_or("Invalid sender")?;
|
||||
|
||||
let iv_hex = get_iv_hex();
|
||||
|
||||
let result = {
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
let security = state_guard.security.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
AuthService::generate_recovery(&mut settings, &security, &mut permissions, sender, &iv_hex)
|
||||
.await
|
||||
};
|
||||
|
||||
if result.success {
|
||||
Ok(IpcResponse::success(SetPasswordsResponse {
|
||||
recovery_string: result.recovery_string,
|
||||
}))
|
||||
} else {
|
||||
Ok(IpcResponse::failure_with_type(
|
||||
&result
|
||||
.message
|
||||
.unwrap_or_else(|| "Failed to generate recovery".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_reset_by_recovery(
|
||||
recovery_string: String,
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SetPasswordsResponse>, String> {
|
||||
let sender = sender_id.ok_or("Invalid sender")?;
|
||||
|
||||
let iv_hex = get_iv_hex();
|
||||
|
||||
let result = {
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
let security = state_guard.security.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
AuthService::reset_by_recovery(
|
||||
&mut settings,
|
||||
&security,
|
||||
&mut permissions,
|
||||
sender,
|
||||
&recovery_string,
|
||||
&iv_hex,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if result.success {
|
||||
Ok(IpcResponse::success(SetPasswordsResponse {
|
||||
recovery_string: result.recovery_string,
|
||||
}))
|
||||
} else {
|
||||
Ok(IpcResponse::failure_with_type(
|
||||
&result
|
||||
.message
|
||||
.unwrap_or_else(|| "Recovery failed".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_clear_all(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let sender = sender_id.ok_or("Invalid sender")?;
|
||||
|
||||
let result = {
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
|
||||
AuthService::clear_all(&mut settings, &mut permissions, sender).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok(IpcResponse::success(())),
|
||||
Err(e) => Ok(IpcResponse::error(&e)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::services::{
|
||||
AutoScoreAction, AutoScoreRule, AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
fn check_admin_permission(
|
||||
permissions: &mut crate::services::PermissionService,
|
||||
sender_id: Option<u32>,
|
||||
) -> bool {
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Admin)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateAutoScoreRule {
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "studentNames")]
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateAutoScoreRule {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "studentNames")]
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToggleRuleParams {
|
||||
#[serde(rename = "ruleId")]
|
||||
pub rule_id: i32,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoScoreStatus {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_get_rules(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<AutoScoreRule>>, 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 state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let rules = auto_score_service.get_rules().to_vec();
|
||||
Ok(IpcResponse::success(rules))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_add_rule(
|
||||
rule: CreateAutoScoreRule,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<i32>, 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 new_id = {
|
||||
let state_guard = state.read();
|
||||
let mut auto_score_service = state_guard.auto_score.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
let new_rule = AutoScoreRule {
|
||||
id: 0,
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
student_names: rule.student_names,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
last_executed: None,
|
||||
};
|
||||
|
||||
let new_id = auto_score_service.add_rule(new_rule);
|
||||
|
||||
let rules_json = auto_score_service.get_rules_json();
|
||||
let _ = settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await;
|
||||
|
||||
new_id
|
||||
};
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules());
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(new_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_update_rule(
|
||||
rule: UpdateAutoScoreRule,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<bool>, 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 success = {
|
||||
let state_guard = state.read();
|
||||
let mut auto_score_service = state_guard.auto_score.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
let existing = auto_score_service.get_rule_by_id(rule.id);
|
||||
let last_executed = existing.and_then(|r| r.last_executed.clone());
|
||||
|
||||
let updated_rule = AutoScoreRule {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
student_names: rule.student_names,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
last_executed,
|
||||
};
|
||||
|
||||
let success = auto_score_service.update_rule(updated_rule);
|
||||
|
||||
if success {
|
||||
let rules_json = auto_score_service.get_rules_json();
|
||||
let _ = settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await;
|
||||
}
|
||||
|
||||
success
|
||||
};
|
||||
|
||||
if success {
|
||||
let state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules());
|
||||
Ok(IpcResponse::success(true))
|
||||
} else {
|
||||
Ok(IpcResponse::error("Rule not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_delete_rule(
|
||||
rule_id: i32,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<bool>, 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 success = {
|
||||
let state_guard = state.read();
|
||||
let mut auto_score_service = state_guard.auto_score.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
let success = auto_score_service.delete_rule(rule_id);
|
||||
|
||||
if success {
|
||||
let rules_json = auto_score_service.get_rules_json();
|
||||
let _ = settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await;
|
||||
}
|
||||
|
||||
success
|
||||
};
|
||||
|
||||
if success {
|
||||
let state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules());
|
||||
Ok(IpcResponse::success(true))
|
||||
} else {
|
||||
Ok(IpcResponse::error("Rule not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_toggle_rule(
|
||||
params: ToggleRuleParams,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<bool>, 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 success = {
|
||||
let state_guard = state.read();
|
||||
let mut auto_score_service = state_guard.auto_score.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
let success = auto_score_service.toggle_rule(params.rule_id, params.enabled);
|
||||
|
||||
if success {
|
||||
let rules_json = auto_score_service.get_rules_json();
|
||||
let _ = settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await;
|
||||
}
|
||||
|
||||
success
|
||||
};
|
||||
|
||||
if success {
|
||||
let state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules());
|
||||
Ok(IpcResponse::success(true))
|
||||
} else {
|
||||
Ok(IpcResponse::error("Rule not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_get_status(
|
||||
sender_id: Option<u32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<AutoScoreStatus>, 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 state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let enabled = auto_score_service.is_enabled();
|
||||
Ok(IpcResponse::success(AutoScoreStatus { enabled }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auto_score_sort_rules(
|
||||
rule_ids: Vec<i32>,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<bool>, 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 state_guard = state.read();
|
||||
let mut auto_score_service = state_guard.auto_score.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
auto_score_service.sort_rules(&rule_ids);
|
||||
|
||||
let rules_json = auto_score_service.get_rules_json();
|
||||
let _ = settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let auto_score_service = state_guard.auto_score.read();
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules());
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(true))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let sender_id = 0;
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied: Admin required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn data_export_json(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<String>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let settings = state_guard.settings.read();
|
||||
let settings_value = settings.get_all();
|
||||
let settings_json = serde_json::to_value(settings_value)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
|
||||
let data_service = state_guard.data.read();
|
||||
let students: Vec<crate::services::data::StudentExport> = Vec::new();
|
||||
let events: Vec<crate::services::data::EventExport> = Vec::new();
|
||||
|
||||
let export_data = data_service.export_json(settings_json, students, events);
|
||||
|
||||
let json_string = serde_json::to_string_pretty(&export_data)
|
||||
.map_err(|e| format!("Failed to serialize export data: {}", e))?;
|
||||
|
||||
Ok(IpcResponse::success(json_string))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn data_import_json(
|
||||
json_text: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let mut data_service = state_guard.data.write();
|
||||
let result = data_service.import_json(&json_text);
|
||||
|
||||
if result.success {
|
||||
Ok(IpcResponse::success_empty())
|
||||
} else {
|
||||
Ok(IpcResponse::error(
|
||||
result.message.as_deref().unwrap_or("Import failed"),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestConnectionResult {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SwitchConnectionResult {
|
||||
#[serde(rename = "type")]
|
||||
pub db_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseStatus {
|
||||
#[serde(rename = "type")]
|
||||
pub db_type: String,
|
||||
pub connected: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncResult {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let sender_id = 0;
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied: Admin required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_test_connection(
|
||||
connection_string: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<TestConnectionResult>, String> {
|
||||
let result = if connection_string.starts_with("sqlite://") {
|
||||
let path = connection_string
|
||||
.strip_prefix("sqlite://")
|
||||
.unwrap_or(&connection_string);
|
||||
match create_sqlite_connection(path).await {
|
||||
Ok(conn) => {
|
||||
let _ = conn.close().await;
|
||||
TestConnectionResult {
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
success: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
} else if connection_string.starts_with("postgres://")
|
||||
|| connection_string.starts_with("postgresql://")
|
||||
{
|
||||
match create_postgres_connection(&connection_string).await {
|
||||
Ok(conn) => {
|
||||
let _ = conn.close().await;
|
||||
TestConnectionResult {
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
success: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
let path = connection_string.as_str();
|
||||
match create_sqlite_connection(path).await {
|
||||
Ok(conn) => {
|
||||
let _ = conn.close().await;
|
||||
TestConnectionResult {
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
success: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(result))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_switch_connection(
|
||||
connection_string: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SwitchConnectionResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let db_type = if connection_string.starts_with("postgres://")
|
||||
|| connection_string.starts_with("postgresql://")
|
||||
{
|
||||
let conn = create_postgres_connection(&connection_string)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let mut db_guard = state_guard.db.write();
|
||||
*db_guard = Some(conn);
|
||||
|
||||
"postgresql".to_string()
|
||||
} else {
|
||||
let path = if connection_string.starts_with("sqlite://") {
|
||||
connection_string
|
||||
.strip_prefix("sqlite://")
|
||||
.unwrap_or(&connection_string)
|
||||
.to_string()
|
||||
} else {
|
||||
connection_string
|
||||
};
|
||||
|
||||
let conn = create_sqlite_connection(&path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let mut db_guard = state_guard.db.write();
|
||||
*db_guard = Some(conn);
|
||||
|
||||
"sqlite".to_string()
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(SwitchConnectionResult { db_type }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_get_status(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<DatabaseStatus>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
let connected = db_guard.is_some();
|
||||
|
||||
let db_type = if connected {
|
||||
let settings = state_guard.settings.read();
|
||||
let status_json =
|
||||
settings.get_value(crate::services::settings::SettingsKey::PgConnectionStatus);
|
||||
match status_json {
|
||||
crate::services::settings::SettingsValue::Json(json) => json
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "sqlite".to_string()),
|
||||
_ => "sqlite".to_string(),
|
||||
}
|
||||
} else {
|
||||
"sqlite".to_string()
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(DatabaseStatus {
|
||||
db_type,
|
||||
connected,
|
||||
error: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SyncResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
match conn.ping().await {
|
||||
Ok(_) => Ok(IpcResponse::success(SyncResult {
|
||||
success: true,
|
||||
message: Some("Database synchronized successfully".to_string()),
|
||||
})),
|
||||
Err(e) => Ok(IpcResponse::success(SyncResult {
|
||||
success: false,
|
||||
message: Some(format!("Sync failed: {}", e)),
|
||||
})),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::success(SyncResult {
|
||||
success: false,
|
||||
message: Some("No database connection".to_string()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use chrono::{Datelike, Duration, TimeZone, Timelike, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
TransactionTrait,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::entities::{score_events, students};
|
||||
use crate::services::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScoreEvent {
|
||||
pub id: i32,
|
||||
pub uuid: String,
|
||||
pub student_name: String,
|
||||
pub reason_content: String,
|
||||
pub delta: i32,
|
||||
pub val_prev: i32,
|
||||
pub val_curr: i32,
|
||||
pub event_time: String,
|
||||
pub settlement_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateScoreEvent {
|
||||
pub student_name: String,
|
||||
pub reason_content: String,
|
||||
pub delta: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryEventParams {
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryByStudentParams {
|
||||
pub student_name: String,
|
||||
pub limit: Option<i32>,
|
||||
pub start_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaderboardRow {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub range_change: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaderboardResult {
|
||||
pub start_time: String,
|
||||
pub rows: Vec<LeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaderboardParams {
|
||||
pub range: String,
|
||||
}
|
||||
|
||||
fn check_points_permission(state: &Arc<RwLock<AppState>>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
permissions.require_permission(0, PermissionLevel::Points)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn event_query(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
params: QueryEventParams,
|
||||
) -> Result<IpcResponse<Vec<ScoreEvent>>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
let limit = params.limit.unwrap_or(100);
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let results = score_events::Entity::find()
|
||||
.filter(score_events::Column::SettlementId.is_null())
|
||||
.order_by_desc(score_events::Column::EventTime)
|
||||
.limit(limit as u64)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match results {
|
||||
Ok(event_models) => {
|
||||
let event_list: Vec<ScoreEvent> = event_models
|
||||
.into_iter()
|
||||
.map(|e| ScoreEvent {
|
||||
id: e.id,
|
||||
uuid: e.uuid,
|
||||
student_name: e.student_name,
|
||||
reason_content: e.reason_content,
|
||||
delta: e.delta,
|
||||
val_prev: e.val_prev,
|
||||
val_curr: e.val_curr,
|
||||
event_time: e.event_time,
|
||||
settlement_id: e.settlement_id,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(event_list))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query events: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn event_create(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
data: CreateScoreEvent,
|
||||
) -> Result<IpcResponse<i32>, String> {
|
||||
if !check_points_permission(&state) {
|
||||
return Ok(IpcResponse::error("Permission denied: points required"));
|
||||
}
|
||||
|
||||
let student_name = data.student_name.trim();
|
||||
if student_name.is_empty() {
|
||||
return Ok(IpcResponse::error("Student name cannot be empty"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let student = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(student_name))
|
||||
.one(conn)
|
||||
.await;
|
||||
|
||||
match student {
|
||||
Ok(Some(student)) => {
|
||||
let val_prev = student.score;
|
||||
let val_curr = val_prev + data.delta;
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let uuid = Uuid::new_v4().to_string();
|
||||
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_event = score_events::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
uuid: Set(uuid),
|
||||
student_name: Set(student_name.to_string()),
|
||||
reason_content: Set(data.reason_content.trim().to_string()),
|
||||
delta: Set(data.delta),
|
||||
val_prev: Set(val_prev),
|
||||
val_curr: Set(val_curr),
|
||||
event_time: Set(now.clone()),
|
||||
settlement_id: Set(None),
|
||||
};
|
||||
|
||||
let inserted = new_event.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut active: students::ActiveModel = student.into();
|
||||
active.score = Set(val_curr);
|
||||
active.updated_at = Set(now);
|
||||
active.update(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success(inserted.id))
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Student not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn event_delete(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
uuid: String,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_points_permission(&state) {
|
||||
return Ok(IpcResponse::error("Permission denied: points required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let event = score_events::Entity::find()
|
||||
.filter(score_events::Column::Uuid.eq(uuid.trim()))
|
||||
.one(conn)
|
||||
.await;
|
||||
|
||||
match event {
|
||||
Ok(Some(event)) => {
|
||||
if event.settlement_id.is_some() {
|
||||
return Ok(IpcResponse::error("该记录已结算,无法撤销"));
|
||||
}
|
||||
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let student = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(&event.student_name))
|
||||
.one(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(student) = student {
|
||||
let new_score = student.score - event.delta;
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
|
||||
let mut active: students::ActiveModel = student.into();
|
||||
active.score = Set(new_score);
|
||||
active.updated_at = Set(now);
|
||||
active.update(&txn).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
score_events::Entity::delete_by_id(event.id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Event not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn event_query_by_student(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
params: QueryByStudentParams,
|
||||
) -> Result<IpcResponse<Vec<ScoreEvent>>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
let limit = params.limit.unwrap_or(50);
|
||||
let student_name = params.student_name.trim();
|
||||
|
||||
if student_name.is_empty() {
|
||||
return Ok(IpcResponse::success(vec![]));
|
||||
}
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let mut query = score_events::Entity::find()
|
||||
.filter(score_events::Column::StudentName.eq(student_name))
|
||||
.filter(score_events::Column::SettlementId.is_null())
|
||||
.order_by_desc(score_events::Column::EventTime)
|
||||
.limit(limit as u64);
|
||||
|
||||
if let Some(start_time) = ¶ms.start_time {
|
||||
query = query.filter(score_events::Column::EventTime.gte(start_time));
|
||||
}
|
||||
|
||||
let results = query.all(conn).await;
|
||||
|
||||
match results {
|
||||
Ok(event_models) => {
|
||||
let event_list: Vec<ScoreEvent> = event_models
|
||||
.into_iter()
|
||||
.map(|e| ScoreEvent {
|
||||
id: e.id,
|
||||
uuid: e.uuid,
|
||||
student_name: e.student_name,
|
||||
reason_content: e.reason_content,
|
||||
delta: e.delta,
|
||||
val_prev: e.val_prev,
|
||||
val_curr: e.val_curr,
|
||||
event_time: e.event_time,
|
||||
settlement_id: e.settlement_id,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(event_list))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query events: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leaderboard_query(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
params: LeaderboardParams,
|
||||
) -> Result<IpcResponse<LeaderboardResult>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let now = Utc::now();
|
||||
let start = match params.range.as_str() {
|
||||
"today" => {
|
||||
let mut s = now;
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
"week" => {
|
||||
let mut s = now;
|
||||
let day = s.weekday().num_days_from_monday() as i64;
|
||||
s = s - Duration::days(day);
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
"month" => {
|
||||
let s = now.with_day0(0).unwrap_or(now);
|
||||
let mut s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
let mut s = now;
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
let start_time = start.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
let student_results = students::Entity::find()
|
||||
.order_by_desc(students::Column::Score)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match student_results {
|
||||
Ok(student_models) => {
|
||||
let mut rows: Vec<LeaderboardRow> = student_models
|
||||
.into_iter()
|
||||
.map(|s| LeaderboardRow {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
score: s.score,
|
||||
range_change: 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for row in &mut rows {
|
||||
let events = score_events::Entity::find()
|
||||
.filter(score_events::Column::StudentName.eq(&row.name))
|
||||
.filter(score_events::Column::SettlementId.is_null())
|
||||
.filter(score_events::Column::EventTime.gte(&start_time))
|
||||
.all(conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
row.range_change = events.iter().map(|e| e.delta as i64).sum();
|
||||
}
|
||||
|
||||
rows.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then(b.range_change.cmp(&a.range_change))
|
||||
.then(a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
Ok(IpcResponse::success(LeaderboardResult { start_time, rows }))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query leaderboard: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use chrono::Local;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFolderStructure {
|
||||
pub config_root: String,
|
||||
pub automatic: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFileInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub modified: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ConfigFolder {
|
||||
#[serde(rename = "automatic")]
|
||||
Automatic,
|
||||
#[serde(rename = "script")]
|
||||
Script,
|
||||
}
|
||||
|
||||
impl ConfigFolder {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ConfigFolder::Automatic => "automatic",
|
||||
ConfigFolder::Script => "script",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"automatic" => Some(ConfigFolder::Automatic),
|
||||
"script" => Some(ConfigFolder::Script),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_config_root() -> PathBuf {
|
||||
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
config_dir.join("SecScore")
|
||||
}
|
||||
|
||||
fn get_folder_path(folder: &ConfigFolder) -> PathBuf {
|
||||
let root = get_config_root();
|
||||
match folder {
|
||||
ConfigFolder::Automatic => root.join("automatic"),
|
||||
ConfigFolder::Script => root.join("script"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_folder_exists(folder: &ConfigFolder) -> Result<PathBuf, String> {
|
||||
let path = get_folder_path(folder);
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_get_config_structure(
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<ConfigFolderStructure>, String> {
|
||||
let config_root = get_config_root();
|
||||
let _ = fs::create_dir_all(&config_root);
|
||||
|
||||
let automatic = config_root.join("automatic");
|
||||
let script = config_root.join("script");
|
||||
|
||||
let _ = fs::create_dir_all(&automatic);
|
||||
let _ = fs::create_dir_all(&script);
|
||||
|
||||
Ok(IpcResponse::success(ConfigFolderStructure {
|
||||
config_root: config_root.to_string_lossy().to_string(),
|
||||
automatic: automatic.to_string_lossy().to_string(),
|
||||
script: script.to_string_lossy().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_read_json(
|
||||
relative_path: String,
|
||||
folder: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<serde_json::Value>, 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::success(serde_json::Value::Null));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
Ok(IpcResponse::success(json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_write_json(
|
||||
relative_path: String,
|
||||
data: serde_json::Value,
|
||||
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 let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(&data)
|
||||
.map_err(|e| format!("Failed to serialize JSON: {}", e))?;
|
||||
|
||||
fs::write(&file_path, content).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_read_text(
|
||||
relative_path: String,
|
||||
folder: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Option<String>>, 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::success(None));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success(Some(content)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_write_text(
|
||||
content: String,
|
||||
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 let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
fs::write(&file_path, content).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_delete_file(
|
||||
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() {
|
||||
if file_path.is_file() {
|
||||
fs::remove_file(&file_path).map_err(|e| e.to_string())?;
|
||||
} else if file_path.is_dir() {
|
||||
fs::remove_dir_all(&file_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_list_files(
|
||||
folder: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<ConfigFileInfo>>, 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 mut files = Vec::new();
|
||||
|
||||
fn collect_files(
|
||||
dir: &PathBuf,
|
||||
base: &PathBuf,
|
||||
files: &mut Vec<ConfigFileInfo>,
|
||||
) -> Result<(), String> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
let metadata = fs::metadata(&path).map_err(|e| e.to_string())?;
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map(|t| {
|
||||
let datetime: chrono::DateTime<Local> = t.into();
|
||||
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let relative = path
|
||||
.strip_prefix(base)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
files.push(ConfigFileInfo {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
path: relative,
|
||||
size: metadata.len(),
|
||||
modified,
|
||||
});
|
||||
} else if path.is_dir() {
|
||||
collect_files(&path, base, files)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
collect_files(&folder_path, &folder_path, &mut files)?;
|
||||
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
Ok(IpcResponse::success(files))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_file_exists(
|
||||
relative_path: String,
|
||||
folder: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<bool>, 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);
|
||||
|
||||
Ok(IpcResponse::success(file_path.exists()))
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpServerConfig {
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cors_origin: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HttpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: 3000,
|
||||
host: "127.0.0.1".to_string(),
|
||||
cors_origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpServerStartResult {
|
||||
pub url: String,
|
||||
pub config: HttpServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpServerStatus {
|
||||
pub is_running: bool,
|
||||
pub config: HttpServerConfig,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct HttpServerState {
|
||||
pub is_running: bool,
|
||||
pub config: HttpServerConfig,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HttpServerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_running: false,
|
||||
config: HttpServerConfig::default(),
|
||||
url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let sender_id = 0;
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied: Admin required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static HTTP_SERVER_STATE: once_cell::sync::Lazy<Arc<Mutex<HttpServerState>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(HttpServerState::default())));
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn http_server_start(
|
||||
config: Option<HttpServerConfig>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<HttpServerStartResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let mut server_state = HTTP_SERVER_STATE.lock().await;
|
||||
|
||||
if server_state.is_running {
|
||||
return Ok(IpcResponse::failure_with_type(
|
||||
"HTTP server is already running",
|
||||
));
|
||||
}
|
||||
|
||||
let config = config.unwrap_or_default();
|
||||
|
||||
let host: std::net::IpAddr = config
|
||||
.host
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid host address: {}", e))?;
|
||||
let addr: SocketAddr = (host, config.port).into();
|
||||
|
||||
let url = format!("http://{}:{}", config.host, config.port);
|
||||
|
||||
server_state.is_running = true;
|
||||
server_state.config = config.clone();
|
||||
server_state.url = Some(url.clone());
|
||||
|
||||
let _ = addr;
|
||||
|
||||
Ok(IpcResponse::success(HttpServerStartResult { url, config }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn http_server_stop(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let mut server_state = HTTP_SERVER_STATE.lock().await;
|
||||
|
||||
if !server_state.is_running {
|
||||
return Ok(IpcResponse::success_empty());
|
||||
}
|
||||
|
||||
server_state.is_running = false;
|
||||
server_state.url = None;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn http_server_status(
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<HttpServerStatus>, String> {
|
||||
let server_state = HTTP_SERVER_STATE.lock().await;
|
||||
|
||||
let status = HttpServerStatus {
|
||||
is_running: server_state.is_running,
|
||||
config: server_state.config.clone(),
|
||||
url: server_state.url.clone(),
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(status))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::logger::LogLevel;
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogWritePayload {
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let sender_id = 0;
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied: Admin required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn log_query(
|
||||
lines: Option<i32>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<String>>, String> {
|
||||
let lines = lines.unwrap_or(100) as usize;
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
let logs = logger.read_logs(lines);
|
||||
|
||||
Ok(IpcResponse::success(logs))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn log_clear(state: State<'_, Arc<RwLock<AppState>>>) -> Result<IpcResponse<()>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
logger.clear_logs().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn log_set_level(
|
||||
level: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let log_level =
|
||||
LogLevel::from_str(&level).ok_or_else(|| format!("Invalid log level: {}", level))?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let mut logger = state_guard.logger.write();
|
||||
logger.set_level(log_level);
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn log_write(
|
||||
payload: LogWritePayload,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let log_level = LogLevel::from_str(&payload.level)
|
||||
.ok_or_else(|| format!("Invalid log level: {}", payload.level))?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
logger.log(log_level, &payload.message, None, payload.meta);
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod auto_score;
|
||||
pub mod data;
|
||||
pub mod database;
|
||||
pub mod event;
|
||||
pub mod filesystem;
|
||||
pub mod http_server;
|
||||
pub mod log;
|
||||
pub mod reason;
|
||||
pub mod response;
|
||||
pub mod settings;
|
||||
pub mod settlement;
|
||||
pub mod student;
|
||||
pub mod tag;
|
||||
pub mod theme;
|
||||
pub mod window;
|
||||
|
||||
pub use app::*;
|
||||
pub use auth::*;
|
||||
pub use auto_score::*;
|
||||
pub use data::*;
|
||||
pub use database::*;
|
||||
pub use event::*;
|
||||
pub use filesystem::*;
|
||||
pub use http_server::*;
|
||||
pub use log::*;
|
||||
pub use reason::*;
|
||||
pub use response::*;
|
||||
pub use settings::*;
|
||||
pub use settlement::*;
|
||||
pub use student::*;
|
||||
pub use tag::*;
|
||||
pub use theme::*;
|
||||
pub use window::*;
|
||||
@@ -0,0 +1,221 @@
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::entities::reasons;
|
||||
use crate::services::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Reason {
|
||||
pub id: i32,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub delta: i32,
|
||||
pub is_system: i32,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateReason {
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub delta: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateReason {
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub delta: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeleteResult {
|
||||
pub changes: i32,
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
permissions.require_permission(0, PermissionLevel::Admin)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reason_query(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<Reason>>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let results = reasons::Entity::find()
|
||||
.order_by_asc(reasons::Column::Category)
|
||||
.order_by_asc(reasons::Column::Content)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match results {
|
||||
Ok(reason_models) => {
|
||||
let reason_list: Vec<Reason> = reason_models
|
||||
.into_iter()
|
||||
.map(|r| Reason {
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
category: r.category,
|
||||
delta: r.delta,
|
||||
is_system: r.is_system,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(reason_list))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query reasons: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reason_create(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
data: CreateReason,
|
||||
) -> Result<IpcResponse<i32>, String> {
|
||||
if !check_admin_permission(&state) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let content = data.content.trim();
|
||||
if content.is_empty() {
|
||||
return Ok(IpcResponse::error("Reason content cannot be empty"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let new_reason = reasons::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
content: Set(content.to_string()),
|
||||
category: Set(data.category.trim().to_string()),
|
||||
delta: Set(data.delta),
|
||||
is_system: Set(0),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
match new_reason.insert(conn).await {
|
||||
Ok(inserted) => Ok(IpcResponse::success(inserted.id)),
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to create reason: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reason_update(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
id: i32,
|
||||
data: UpdateReason,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_admin_permission(&state) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = reasons::Entity::find_by_id(id).one(conn).await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(reason)) => {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let mut active: reasons::ActiveModel = reason.into();
|
||||
|
||||
active.updated_at = Set(now);
|
||||
|
||||
if let Some(content) = data.content {
|
||||
active.content = Set(content);
|
||||
}
|
||||
if let Some(category) = data.category {
|
||||
active.category = Set(category);
|
||||
}
|
||||
if let Some(delta) = data.delta {
|
||||
active.delta = Set(delta);
|
||||
}
|
||||
|
||||
match active.update(conn).await {
|
||||
Ok(_) => Ok(IpcResponse::success_empty()),
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to update reason: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Reason not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reason_delete(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
id: i32,
|
||||
) -> Result<IpcResponse<DeleteResult>, String> {
|
||||
if !check_admin_permission(&state) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = reasons::Entity::find_by_id(id).one(conn).await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(reason)) => {
|
||||
let delete_result = reasons::Entity::delete_by_id(reason.id).exec(conn).await;
|
||||
|
||||
match delete_result {
|
||||
Ok(result) => {
|
||||
if result.rows_affected == 0 {
|
||||
Ok(IpcResponse::error("记录不存在"))
|
||||
} else {
|
||||
Ok(IpcResponse::success(DeleteResult {
|
||||
changes: result.rows_affected as i32,
|
||||
}))
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to delete reason: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("记录不存在")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IpcResponse<T> {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> IpcResponse<T> {
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: &str) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
message: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure_with_type(message: &str) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
message: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IpcResponse<()> {
|
||||
pub fn success_empty() -> Self {
|
||||
IpcResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportResult {
|
||||
pub inserted: usize,
|
||||
pub skipped: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TagResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::services::{
|
||||
settings::{
|
||||
PermissionRequirement, SettingValueKind, SettingsKey, SettingsService, SettingsSpec,
|
||||
SettingsValue,
|
||||
},
|
||||
PermissionLevel, PermissionService,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingChange {
|
||||
pub key: String,
|
||||
pub value: JsonValue,
|
||||
}
|
||||
|
||||
fn get_write_permission(key: SettingsKey) -> PermissionRequirement {
|
||||
let definitions = SettingsService::get_definitions();
|
||||
if let Some(def) = definitions.get(&key) {
|
||||
def.write_permission
|
||||
} else {
|
||||
PermissionRequirement::Any
|
||||
}
|
||||
}
|
||||
|
||||
fn check_write_permission(
|
||||
permissions: &mut PermissionService,
|
||||
sender_id: Option<u32>,
|
||||
requirement: PermissionRequirement,
|
||||
) -> bool {
|
||||
match requirement {
|
||||
PermissionRequirement::Any => true,
|
||||
PermissionRequirement::Admin => {
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Admin)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
PermissionRequirement::Points => {
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Points)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
PermissionRequirement::View => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_value_to_json(value: SettingsValue) -> JsonValue {
|
||||
match value {
|
||||
SettingsValue::Boolean(b) => JsonValue::Bool(b),
|
||||
SettingsValue::String(s) => JsonValue::String(s),
|
||||
SettingsValue::Number(n) => JsonValue::Number(
|
||||
serde_json::Number::from_f64(n).unwrap_or(serde_json::Number::from(0)),
|
||||
),
|
||||
SettingsValue::Json(j) => j,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_settings_value(json: JsonValue, kind: SettingValueKind) -> SettingsValue {
|
||||
match kind {
|
||||
SettingValueKind::Boolean => SettingsValue::Boolean(json.as_bool().unwrap_or(false)),
|
||||
SettingValueKind::String => SettingsValue::String(json.as_str().unwrap_or("").to_string()),
|
||||
SettingValueKind::Number => SettingsValue::Number(json.as_f64().unwrap_or(0.0)),
|
||||
SettingValueKind::Json => SettingsValue::Json(json),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_get_all(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SettingsSpec>, String> {
|
||||
let state_guard = state.read();
|
||||
let settings = state_guard.settings.read();
|
||||
let all = settings.get_all();
|
||||
Ok(IpcResponse::success(all))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_get(
|
||||
key: String,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<JsonValue>, String> {
|
||||
let settings_key =
|
||||
SettingsKey::from_str(&key).ok_or_else(|| format!("Unknown setting key: {}", key))?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let settings = state_guard.settings.read();
|
||||
let value = settings.get_value(settings_key);
|
||||
let json_value = settings_value_to_json(value);
|
||||
|
||||
Ok(IpcResponse::success(json_value))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_set(
|
||||
key: String,
|
||||
value: JsonValue,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let settings_key =
|
||||
SettingsKey::from_str(&key).ok_or_else(|| format!("Unknown setting key: {}", key))?;
|
||||
|
||||
let write_permission = get_write_permission(settings_key);
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if !check_write_permission(&mut permissions, sender_id, write_permission) {
|
||||
return Ok(IpcResponse::error("Permission denied"));
|
||||
}
|
||||
}
|
||||
|
||||
let definitions = SettingsService::get_definitions();
|
||||
let kind = definitions
|
||||
.get(&settings_key)
|
||||
.map(|d| d.kind)
|
||||
.unwrap_or(SettingValueKind::String);
|
||||
|
||||
let settings_value = json_to_settings_value(value.clone(), kind);
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let mut settings = state_guard.settings.write();
|
||||
settings
|
||||
.set_value(settings_key, settings_value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let change = SettingChange {
|
||||
key: key.clone(),
|
||||
value,
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("settings:changed", &change);
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::models::{SettlementResult, SettlementSummary};
|
||||
use crate::services::permission::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementInfo {
|
||||
pub id: i32,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementLeaderboardRow {
|
||||
pub name: String,
|
||||
pub score: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementLeaderboard {
|
||||
pub settlement: SettlementInfo,
|
||||
pub rows: Vec<SettlementLeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementLeaderboardParams {
|
||||
pub settlement_id: i32,
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
let sender_id = 0;
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied: Admin required".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_settlement_query(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<SettlementSummary>>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
if db_guard.is_none() {
|
||||
return Ok(IpcResponse::success(Vec::new()));
|
||||
}
|
||||
|
||||
let summaries: Vec<SettlementSummary> = Vec::new();
|
||||
|
||||
Ok(IpcResponse::success(summaries))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_settlement_create(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SettlementResult>, String> {
|
||||
check_admin_permission(&state)?;
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
if db_guard.is_none() {
|
||||
return Ok(IpcResponse::failure_with_type("No database connection"));
|
||||
}
|
||||
|
||||
let result = SettlementResult {
|
||||
settlement_id: 0,
|
||||
start_time: chrono::Local::now().to_rfc3339(),
|
||||
end_time: chrono::Local::now().to_rfc3339(),
|
||||
event_count: 0,
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(result))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_settlement_leaderboard(
|
||||
params: SettlementLeaderboardParams,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<SettlementLeaderboard>, String> {
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
if db_guard.is_none() {
|
||||
return Ok(IpcResponse::failure_with_type("No database connection"));
|
||||
}
|
||||
|
||||
let leaderboard = SettlementLeaderboard {
|
||||
settlement: SettlementInfo {
|
||||
id: params.settlement_id,
|
||||
start_time: String::new(),
|
||||
end_time: String::new(),
|
||||
},
|
||||
rows: Vec::new(),
|
||||
};
|
||||
|
||||
Ok(IpcResponse::success(leaderboard))
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::entities::students;
|
||||
use crate::models::{StudentUpdate, StudentWithTags};
|
||||
use crate::services::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::{ImportResult, IpcResponse};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateStudentData {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportStudentsParams {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Admin)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::View)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn student_query(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
) -> Result<IpcResponse<Vec<StudentWithTags>>, String> {
|
||||
if !check_view_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let results = students::Entity::find()
|
||||
.order_by_desc(students::Column::Score)
|
||||
.order_by_asc(students::Column::Name)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match results {
|
||||
Ok(student_models) => {
|
||||
let students: Vec<StudentWithTags> = student_models
|
||||
.into_iter()
|
||||
.map(|s| StudentWithTags {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
score: s.score,
|
||||
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
|
||||
extra_json: s.extra_json,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(students))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query students: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn student_create(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
data: CreateStudentData,
|
||||
) -> Result<IpcResponse<i32>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let name = data.name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(IpcResponse::error("Student name cannot be empty"));
|
||||
}
|
||||
if name.len() > 64 {
|
||||
return Ok(IpcResponse::error(
|
||||
"Student name too long (max 64 characters)",
|
||||
));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = students::Entity::find()
|
||||
.filter(students::Column::Name.eq(name))
|
||||
.one(conn)
|
||||
.await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(_)) => Ok(IpcResponse::error("Student with this name already exists")),
|
||||
Ok(None) => {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let new_student = students::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(name.to_string()),
|
||||
score: Set(0),
|
||||
tags: Set("[]".to_string()),
|
||||
extra_json: Set(None),
|
||||
created_at: Set(now.clone()),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
match new_student.insert(conn).await {
|
||||
Ok(inserted) => Ok(IpcResponse::success(inserted.id)),
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to create student: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn student_update(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
id: i32,
|
||||
data: StudentUpdate,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = students::Entity::find_by_id(id).one(conn).await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(student)) => {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let mut active: students::ActiveModel = student.into();
|
||||
|
||||
active.updated_at = Set(now);
|
||||
|
||||
if let Some(name) = data.name {
|
||||
active.name = Set(name);
|
||||
}
|
||||
if let Some(score) = data.score {
|
||||
active.score = Set(score);
|
||||
}
|
||||
if let Some(tags) = data.tags {
|
||||
active.tags =
|
||||
Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()));
|
||||
}
|
||||
if let Some(extra_json) = data.extra_json {
|
||||
active.extra_json = Set(Some(extra_json));
|
||||
}
|
||||
|
||||
match active.update(conn).await {
|
||||
Ok(_) => Ok(IpcResponse::success_empty()),
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to update student: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Student not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn student_delete(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
id: i32,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = students::Entity::find_by_id(id).one(conn).await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(student)) => {
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
students::Entity::delete(students::ActiveModel {
|
||||
id: sea_orm::ActiveValue::Set(student.id),
|
||||
name: sea_orm::ActiveValue::Unchanged(student.name),
|
||||
score: sea_orm::ActiveValue::Unchanged(student.score),
|
||||
tags: sea_orm::ActiveValue::Unchanged(student.tags),
|
||||
extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json),
|
||||
created_at: sea_orm::ActiveValue::Unchanged(student.created_at),
|
||||
updated_at: sea_orm::ActiveValue::Unchanged(student.updated_at),
|
||||
})
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Student not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn student_import_from_xlsx(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
params: ImportStudentsParams,
|
||||
) -> Result<IpcResponse<ImportResult>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let cleaned: Vec<String> = params
|
||||
.names
|
||||
.into_iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty() && n.len() <= 64)
|
||||
.collect();
|
||||
|
||||
let unique_names: std::collections::HashSet<String> = cleaned.into_iter().collect();
|
||||
let unique_names: Vec<String> = unique_names.into_iter().collect();
|
||||
|
||||
if unique_names.is_empty() {
|
||||
return Ok(IpcResponse::success(ImportResult {
|
||||
inserted: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = students::Entity::find()
|
||||
.filter(
|
||||
students::Column::Name
|
||||
.is_in(unique_names.iter().map(|s| s.as_str()).collect::<Vec<_>>()),
|
||||
)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match existing {
|
||||
Ok(existing_students) => {
|
||||
let existing_names: std::collections::HashSet<String> =
|
||||
existing_students.into_iter().map(|s| s.name).collect();
|
||||
|
||||
let to_insert: Vec<&String> = unique_names
|
||||
.iter()
|
||||
.filter(|n| !existing_names.contains(*n))
|
||||
.collect();
|
||||
|
||||
let inserted = to_insert.len();
|
||||
let skipped = unique_names.len() - inserted;
|
||||
|
||||
if !to_insert.is_empty() {
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
|
||||
for name in &to_insert {
|
||||
let new_student = students::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(name.to_string()),
|
||||
score: Set(0),
|
||||
tags: Set("[]".to_string()),
|
||||
extra_json: Set(None),
|
||||
created_at: Set(now.clone()),
|
||||
updated_at: Set(now.clone()),
|
||||
};
|
||||
new_student.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(ImportResult {
|
||||
inserted,
|
||||
skipped,
|
||||
total: unique_names.len(),
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::entities::{student_tags, tags};
|
||||
use crate::services::PermissionLevel;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::{IpcResponse, TagResponse};
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Admin)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||
let state_guard = state.read();
|
||||
let mut permissions = state_guard.permissions.write();
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::View)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tags_get_all(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
) -> Result<IpcResponse<Vec<TagResponse>>, String> {
|
||||
if !check_view_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let results = tags::Entity::find()
|
||||
.order_by_asc(tags::Column::CreatedAt)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match results {
|
||||
Ok(tag_models) => {
|
||||
let tags_response: Vec<TagResponse> = tag_models
|
||||
.into_iter()
|
||||
.map(|t| TagResponse {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(tags_response))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Failed to query tags: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tags_get_by_student(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
student_id: i32,
|
||||
) -> Result<IpcResponse<Vec<TagResponse>>, String> {
|
||||
if !check_view_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let results = student_tags::Entity::find()
|
||||
.filter(student_tags::Column::StudentId.eq(student_id))
|
||||
.order_by_asc(student_tags::Column::CreatedAt)
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match results {
|
||||
Ok(student_tag_models) => {
|
||||
let tag_ids: Vec<i32> = student_tag_models.iter().map(|st| st.tag_id).collect();
|
||||
|
||||
if tag_ids.is_empty() {
|
||||
return Ok(IpcResponse::success(vec![]));
|
||||
}
|
||||
|
||||
let tag_results = tags::Entity::find()
|
||||
.filter(tags::Column::Id.is_in(tag_ids))
|
||||
.all(conn)
|
||||
.await;
|
||||
|
||||
match tag_results {
|
||||
Ok(tag_models) => {
|
||||
let tags_response: Vec<TagResponse> = tag_models
|
||||
.into_iter()
|
||||
.map(|t| TagResponse {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
})
|
||||
.collect();
|
||||
Ok(IpcResponse::success(tags_response))
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Failed to query tags: {}", e))),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!(
|
||||
"Failed to query student tags: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tags_create(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
name: String,
|
||||
) -> Result<IpcResponse<TagResponse>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(IpcResponse::error("Tag name cannot be empty"));
|
||||
}
|
||||
if name.len() > 32 {
|
||||
return Ok(IpcResponse::error("Tag name too long (max 32 characters)"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Name.eq(name))
|
||||
.one(conn)
|
||||
.await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(_)) => Ok(IpcResponse::error("Tag with this name already exists")),
|
||||
Ok(None) => {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
let new_tag = tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(name.to_string()),
|
||||
created_at: Set(now.clone()),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
match new_tag.insert(conn).await {
|
||||
Ok(inserted) => Ok(IpcResponse::success(TagResponse {
|
||||
id: inserted.id,
|
||||
name: inserted.name,
|
||||
})),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Failed to create tag: {}", e))),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tags_delete(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
id: i32,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let existing = tags::Entity::find_by_id(id).one(conn).await;
|
||||
|
||||
match existing {
|
||||
Ok(Some(tag)) => {
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
student_tags::Entity::delete_many()
|
||||
.filter(student_tags::Column::TagId.eq(tag.id))
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
tags::Entity::delete(tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::Set(tag.id),
|
||||
name: sea_orm::ActiveValue::Unchanged(tag.name),
|
||||
created_at: sea_orm::ActiveValue::Unchanged(tag.created_at),
|
||||
updated_at: sea_orm::ActiveValue::Unchanged(tag.updated_at),
|
||||
})
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
Ok(None) => Ok(IpcResponse::error("Tag not found")),
|
||||
Err(e) => Ok(IpcResponse::error(&format!("Database error: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tags_update_student_tags(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
sender_id: Option<u32>,
|
||||
student_id: i32,
|
||||
tag_ids: Vec<i32>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
if !check_admin_permission(&state, sender_id) {
|
||||
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||
}
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_guard = state_guard.db.read();
|
||||
|
||||
if let Some(conn) = db_guard.as_ref() {
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
student_tags::Entity::delete_many()
|
||||
.filter(student_tags::Column::StudentId.eq(student_id))
|
||||
.exec(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !tag_ids.is_empty() {
|
||||
let now = chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
|
||||
for tag_id in tag_ids {
|
||||
let new_student_tag = student_tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
student_id: Set(student_id),
|
||||
tag_id: Set(tag_id),
|
||||
created_at: Set(now.clone()),
|
||||
};
|
||||
new_student_tag
|
||||
.insert(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(IpcResponse::success_empty())
|
||||
} else {
|
||||
Ok(IpcResponse::error("Database not connected"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::services::{PermissionLevel, SettingsKey, SettingsValue, ThemeConfig};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
fn check_admin_permission(
|
||||
permissions: &mut crate::services::PermissionService,
|
||||
sender_id: Option<u32>,
|
||||
) -> bool {
|
||||
if let Some(id) = sender_id {
|
||||
permissions.require_permission(id, PermissionLevel::Admin)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn theme_list(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<ThemeConfig>>, String> {
|
||||
let state_guard = state.read();
|
||||
let theme_service = state_guard.theme.read();
|
||||
let themes = theme_service.get_theme_list();
|
||||
Ok(IpcResponse::success(themes))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn theme_current(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<ThemeConfig>, String> {
|
||||
let state_guard = state.read();
|
||||
let theme_service = state_guard.theme.read();
|
||||
match theme_service.get_current_theme() {
|
||||
Some(theme) => Ok(IpcResponse::success(theme)),
|
||||
None => Ok(IpcResponse::error("No current theme found")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn theme_set(
|
||||
theme_id: String,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, 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 current_theme = {
|
||||
let state_guard = state.read();
|
||||
let mut theme_service = state_guard.theme.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
if theme_service.set_current_theme(&theme_id) {
|
||||
let _ = settings
|
||||
.set_value(
|
||||
SettingsKey::CurrentThemeId,
|
||||
SettingsValue::String(theme_id.clone()),
|
||||
)
|
||||
.await;
|
||||
theme_service.get_current_theme()
|
||||
} else {
|
||||
return Ok(IpcResponse::error("Theme not found"));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(theme) = current_theme {
|
||||
let _ = app_handle.emit("theme:updated", &theme);
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn theme_save(
|
||||
theme: ThemeConfig,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, 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 current_theme = {
|
||||
let state_guard = state.read();
|
||||
let mut theme_service = state_guard.theme.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
match theme_service.save_theme(theme) {
|
||||
Ok(()) => {
|
||||
let custom_themes_json = theme_service.get_custom_themes_json();
|
||||
let _ = settings
|
||||
.set_value(
|
||||
SettingsKey::ThemesCustom,
|
||||
SettingsValue::Json(custom_themes_json),
|
||||
)
|
||||
.await;
|
||||
theme_service.get_current_theme()
|
||||
}
|
||||
Err(e) => return Ok(IpcResponse::error(&e)),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(theme) = current_theme {
|
||||
let _ = app_handle.emit("theme:updated", &theme);
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn theme_delete(
|
||||
theme_id: String,
|
||||
sender_id: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, 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 current_theme = {
|
||||
let state_guard = state.read();
|
||||
let mut theme_service = state_guard.theme.write();
|
||||
let mut settings = state_guard.settings.write();
|
||||
|
||||
match theme_service.delete_theme(&theme_id) {
|
||||
Ok(()) => {
|
||||
let custom_themes_json = theme_service.get_custom_themes_json();
|
||||
let _ = settings
|
||||
.set_value(
|
||||
SettingsKey::ThemesCustom,
|
||||
SettingsValue::Json(custom_themes_json),
|
||||
)
|
||||
.await;
|
||||
|
||||
let current_theme_id = theme_service.get_current_theme_id().to_string();
|
||||
let _ = settings
|
||||
.set_value(
|
||||
SettingsKey::CurrentThemeId,
|
||||
SettingsValue::String(current_theme_id),
|
||||
)
|
||||
.await;
|
||||
theme_service.get_current_theme()
|
||||
}
|
||||
Err(e) => return Ok(IpcResponse::error(&e)),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(theme) = current_theme {
|
||||
let _ = app_handle.emit("theme:updated", &theme);
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn window_minimize(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.minimize().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn window_maximize(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let is_maximized = window.is_maximized().map_err(|e| e.to_string())?;
|
||||
|
||||
if is_maximized {
|
||||
window.unmaximize().map_err(|e| e.to_string())?;
|
||||
Ok(false)
|
||||
} else {
|
||||
window.maximize().map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
} else {
|
||||
Err("Window not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn window_close(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn window_is_maximized(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.is_maximized().map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_devtools(
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn window_resize(
|
||||
width: u32,
|
||||
height: u32,
|
||||
app: AppHandle,
|
||||
_state: tauri::State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.set_size(tauri::Size::Physical(tauri::PhysicalSize { width, height }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DatabaseType {
|
||||
SQLite,
|
||||
PostgreSQL,
|
||||
}
|
||||
|
||||
impl FromStr for DatabaseType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"sqlite" => Ok(Self::SQLite),
|
||||
"postgres" | "postgresql" => Ok(Self::PostgreSQL),
|
||||
_ => Err(format!("Unknown database type: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabaseConfig {
|
||||
pub db_type: DatabaseType,
|
||||
pub sqlite_path: String,
|
||||
pub postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
db_type: DatabaseType::SQLite,
|
||||
sqlite_path: "secscore.db".to_string(),
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseConfig {
|
||||
pub fn sqlite(path: String) -> Self {
|
||||
Self {
|
||||
db_type: DatabaseType::SQLite,
|
||||
sqlite_path: path,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn postgres(url: String) -> Self {
|
||||
Self {
|
||||
db_type: DatabaseType::PostgreSQL,
|
||||
sqlite_path: String::new(),
|
||||
postgres_url: Some(url),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_url(&self) -> String {
|
||||
match self.db_type {
|
||||
DatabaseType::SQLite => format!("sqlite://{}?mode=rwc", self.sqlite_path),
|
||||
DatabaseType::PostgreSQL => self.postgres_url.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectionManager {
|
||||
connection: Arc<RwLock<Option<DatabaseConnection>>>,
|
||||
config: Arc<RwLock<DatabaseConfig>>,
|
||||
}
|
||||
|
||||
impl Clone for ConnectionManager {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
connection: self.connection.clone(),
|
||||
config: self.config.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(RwLock::new(None)),
|
||||
config: Arc::new(RwLock::new(DatabaseConfig::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&self, config: DatabaseConfig) -> Result<(), DbErr> {
|
||||
let url = config.connection_url();
|
||||
info!(
|
||||
"Connecting to database: {} (type: {:?})",
|
||||
if config.db_type == DatabaseType::PostgreSQL {
|
||||
"postgresql://***"
|
||||
} else {
|
||||
&url
|
||||
},
|
||||
config.db_type
|
||||
);
|
||||
|
||||
let mut opt = ConnectOptions::new(&url);
|
||||
opt.max_connections(100)
|
||||
.min_connections(5)
|
||||
.sqlx_logging(true)
|
||||
.sqlx_logging_level(tracing::log::LevelFilter::Info);
|
||||
|
||||
let conn = Database::connect(opt).await?;
|
||||
|
||||
let mut connection_guard = self.connection.write().await;
|
||||
*connection_guard = Some(conn);
|
||||
|
||||
let mut config_guard = self.config.write().await;
|
||||
*config_guard = config;
|
||||
|
||||
info!("Database connection established successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect_sqlite(&self, path: &str) -> Result<(), DbErr> {
|
||||
let config = DatabaseConfig::sqlite(path.to_string());
|
||||
self.connect(config).await
|
||||
}
|
||||
|
||||
pub async fn connect_postgres(&self, url: &str) -> Result<(), DbErr> {
|
||||
let config = DatabaseConfig::postgres(url.to_string());
|
||||
self.connect(config).await
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) -> Result<(), DbErr> {
|
||||
let mut connection_guard = self.connection.write().await;
|
||||
if let Some(conn) = connection_guard.take() {
|
||||
conn.close().await?;
|
||||
info!("Database connection closed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_connection(&self) -> Option<DatabaseConnection> {
|
||||
let guard = self.connection.read().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
let guard = self.connection.read().await;
|
||||
guard.is_some()
|
||||
}
|
||||
|
||||
pub async fn get_config(&self) -> DatabaseConfig {
|
||||
let guard = self.config.read().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
pub async fn get_database_type(&self) -> DatabaseType {
|
||||
let guard = self.config.read().await;
|
||||
guard.db_type.clone()
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self) -> Result<bool, DbErr> {
|
||||
let conn = self.get_connection().await;
|
||||
if let Some(conn) = conn {
|
||||
match conn.ping().await {
|
||||
Ok(_) => {
|
||||
info!("Database connection test successful");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Database connection test failed: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("No database connection available");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn switch_database(&self, config: DatabaseConfig) -> Result<(), DbErr> {
|
||||
info!("Switching database to type: {:?}", config.db_type);
|
||||
|
||||
self.disconnect().await?;
|
||||
|
||||
self.connect(config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_sqlite_connection(path: &str) -> Result<DatabaseConnection, DbErr> {
|
||||
let url = format!("sqlite://{}?mode=rwc", path);
|
||||
let mut opt = ConnectOptions::new(&url);
|
||||
opt.max_connections(100)
|
||||
.min_connections(5)
|
||||
.sqlx_logging(true)
|
||||
.sqlx_logging_level(tracing::log::LevelFilter::Info);
|
||||
|
||||
Database::connect(opt).await
|
||||
}
|
||||
|
||||
pub async fn create_postgres_connection(url: &str) -> Result<DatabaseConnection, DbErr> {
|
||||
let mut opt = ConnectOptions::new(url);
|
||||
opt.max_connections(100)
|
||||
.min_connections(5)
|
||||
.sqlx_logging(true)
|
||||
.sqlx_logging_level(tracing::log::LevelFilter::Info);
|
||||
|
||||
Database::connect(opt).await
|
||||
}
|
||||
|
||||
pub async fn test_sqlite_connection(path: &str) -> Result<bool, DbErr> {
|
||||
let conn = create_sqlite_connection(path).await?;
|
||||
conn.ping().await?;
|
||||
conn.close().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn test_postgres_connection(url: &str) -> Result<bool, DbErr> {
|
||||
let conn = create_postgres_connection(url).await?;
|
||||
conn.ping().await?;
|
||||
conn.close().await?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod reasons;
|
||||
pub mod score_events;
|
||||
pub mod student_tags;
|
||||
pub mod students;
|
||||
pub mod tags;
|
||||
|
||||
pub use reasons::Entity as Reasons;
|
||||
pub use score_events::Entity as ScoreEvents;
|
||||
pub use student_tags::Entity as StudentTags;
|
||||
pub use students::Entity as Students;
|
||||
pub use tags::Entity as Tags;
|
||||
@@ -0,0 +1,19 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "reasons")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub delta: i32,
|
||||
pub is_system: i32,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,22 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "score_events")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub uuid: String,
|
||||
pub student_name: String,
|
||||
pub reason_content: String,
|
||||
pub delta: i32,
|
||||
pub val_prev: i32,
|
||||
pub val_curr: i32,
|
||||
pub event_time: String,
|
||||
pub settlement_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "student_tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub student_id: i32,
|
||||
pub tag_id: i32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::students::Entity",
|
||||
from = "Column::StudentId",
|
||||
to = "super::students::Column::Id"
|
||||
)]
|
||||
Student,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::tags::Entity",
|
||||
from = "Column::TagId",
|
||||
to = "super::tags::Column::Id"
|
||||
)]
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl Related<super::students::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Student.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Tag.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,29 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "students")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub tags: String,
|
||||
pub extra_json: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::student_tags::Entity")]
|
||||
StudentTags,
|
||||
}
|
||||
|
||||
impl Related<super::student_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::StudentTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,26 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::student_tags::Entity")]
|
||||
StudentTags,
|
||||
}
|
||||
|
||||
impl Related<super::student_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::StudentTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,333 @@
|
||||
use sea_orm::{ConnectionTrait, DbBackend, DbErr, Statement};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::connection::DatabaseType;
|
||||
use super::schema::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl Migration {
|
||||
pub async fn run(conn: &impl ConnectionTrait, db_type: DatabaseType) -> Result<(), DbErr> {
|
||||
info!("Starting database migration for {:?}", db_type);
|
||||
|
||||
let is_sqlite = db_type == DatabaseType::SQLite;
|
||||
|
||||
Self::create_students_table(conn, is_sqlite).await?;
|
||||
Self::create_reasons_table(conn, is_sqlite).await?;
|
||||
Self::create_score_events_table(conn, is_sqlite).await?;
|
||||
Self::create_settlements_table(conn, is_sqlite).await?;
|
||||
Self::create_settings_table(conn, is_sqlite).await?;
|
||||
Self::create_tags_table(conn, is_sqlite).await?;
|
||||
Self::create_student_tags_table(conn, is_sqlite).await?;
|
||||
|
||||
Self::create_indexes(conn, is_sqlite).await?;
|
||||
|
||||
Self::insert_default_data(conn, is_sqlite).await?;
|
||||
|
||||
info!("Database migration completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_students_table(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let sql = get_create_students_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created students table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_reasons_table(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let sql = get_create_reasons_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created reasons table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_score_events_table(
|
||||
conn: &impl ConnectionTrait,
|
||||
sqlite: bool,
|
||||
) -> Result<(), DbErr> {
|
||||
let sql = get_create_score_events_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created score_events table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_settlements_table(
|
||||
conn: &impl ConnectionTrait,
|
||||
sqlite: bool,
|
||||
) -> Result<(), DbErr> {
|
||||
let sql = get_create_settlements_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created settlements table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_settings_table(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let sql = get_create_settings_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created settings table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_tags_table(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let sql = get_create_tags_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created tags table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_student_tags_table(
|
||||
conn: &impl ConnectionTrait,
|
||||
sqlite: bool,
|
||||
) -> Result<(), DbErr> {
|
||||
let sql = get_create_student_tags_table_sql(sqlite);
|
||||
conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
info!("Created student_tags table");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let indexes = vec![
|
||||
get_create_index_score_events_settlement_id_sql(sqlite),
|
||||
get_create_index_score_events_student_name_sql(sqlite),
|
||||
get_create_index_reasons_content_sql(sqlite),
|
||||
];
|
||||
|
||||
for index_sql in indexes {
|
||||
conn.execute(Statement::from_string(
|
||||
Self::get_db_backend(sqlite),
|
||||
index_sql,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
info!("Created indexes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_default_data(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
Self::insert_default_reasons(conn, sqlite).await?;
|
||||
Self::insert_default_tags(conn, sqlite).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_default_reasons(
|
||||
conn: &impl ConnectionTrait,
|
||||
sqlite: bool,
|
||||
) -> Result<(), DbErr> {
|
||||
let default_reasons = vec![
|
||||
("课堂表现优秀", "课堂表现", 5, 1),
|
||||
("作业完成优秀", "作业", 3, 1),
|
||||
("帮助同学", "行为", 2, 1),
|
||||
("违反纪律", "行为", -3, 1),
|
||||
("作业未完成", "作业", -2, 1),
|
||||
("迟到", "考勤", -1, 1),
|
||||
("早退", "考勤", -1, 1),
|
||||
("旷课", "考勤", -5, 1),
|
||||
("考试作弊", "考试", -10, 1),
|
||||
("其他", "其他", 0, 1),
|
||||
];
|
||||
|
||||
let db_backend = Self::get_db_backend(sqlite);
|
||||
|
||||
for (content, category, delta, is_system) in default_reasons {
|
||||
let check_sql = format!(
|
||||
"SELECT COUNT(*) as count FROM reasons WHERE content = '{}'",
|
||||
content.replace("'", "''")
|
||||
);
|
||||
|
||||
let result = conn
|
||||
.query_one(Statement::from_string(db_backend.clone(), check_sql))
|
||||
.await?;
|
||||
|
||||
let exists = if let Some(row) = result {
|
||||
let count: i64 = row.try_get("", "count")?;
|
||||
count > 0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !exists {
|
||||
let insert_sql = format!(
|
||||
"INSERT INTO reasons (content, category, delta, is_system) VALUES ('{}', '{}', {}, {})",
|
||||
content.replace("'", "''"),
|
||||
category.replace("'", "''"),
|
||||
delta,
|
||||
is_system
|
||||
);
|
||||
|
||||
conn.execute(Statement::from_string(db_backend.clone(), insert_sql))
|
||||
.await?;
|
||||
info!("Inserted default reason: {}", content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_default_tags(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
let default_tags = vec!["优秀", "良好", "待进步"];
|
||||
|
||||
let db_backend = Self::get_db_backend(sqlite);
|
||||
|
||||
for tag_name in default_tags {
|
||||
let check_sql = format!(
|
||||
"SELECT COUNT(*) as count FROM tags WHERE name = '{}'",
|
||||
tag_name.replace("'", "''")
|
||||
);
|
||||
|
||||
let result = conn
|
||||
.query_one(Statement::from_string(db_backend.clone(), check_sql))
|
||||
.await?;
|
||||
|
||||
let exists = if let Some(row) = result {
|
||||
let count: i64 = row.try_get("", "count")?;
|
||||
count > 0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !exists {
|
||||
let insert_sql = format!(
|
||||
"INSERT INTO tags (name) VALUES ('{}')",
|
||||
tag_name.replace("'", "''")
|
||||
);
|
||||
|
||||
conn.execute(Statement::from_string(db_backend.clone(), insert_sql))
|
||||
.await?;
|
||||
info!("Inserted default tag: {}", tag_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_db_backend(sqlite: bool) -> DbBackend {
|
||||
if sqlite {
|
||||
DbBackend::Sqlite
|
||||
} else {
|
||||
DbBackend::Postgres
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_table_exists(
|
||||
conn: &impl ConnectionTrait,
|
||||
table_name: &str,
|
||||
sqlite: bool,
|
||||
) -> Result<bool, DbErr> {
|
||||
let sql = if sqlite {
|
||||
format!(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='{}'",
|
||||
table_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = '{}'",
|
||||
table_name
|
||||
)
|
||||
};
|
||||
|
||||
let result = conn
|
||||
.query_one(Statement::from_string(Self::get_db_backend(sqlite), sql))
|
||||
.await?;
|
||||
|
||||
Ok(result.is_some())
|
||||
}
|
||||
|
||||
pub async fn drop_all_tables(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
warn!("Dropping all tables...");
|
||||
|
||||
let tables = vec![
|
||||
TABLE_STUDENT_TAGS,
|
||||
TABLE_SCORE_EVENTS,
|
||||
TABLE_SETTLEMENTS,
|
||||
TABLE_REASONS,
|
||||
TABLE_TAGS,
|
||||
TABLE_STUDENTS,
|
||||
TABLE_SETTINGS,
|
||||
];
|
||||
|
||||
let db_backend = Self::get_db_backend(sqlite);
|
||||
|
||||
for table in tables {
|
||||
let sql = format!("DROP TABLE IF EXISTS {}", table);
|
||||
conn.execute(Statement::from_string(db_backend.clone(), sql))
|
||||
.await?;
|
||||
info!("Dropped table: {}", table);
|
||||
}
|
||||
|
||||
info!("All tables dropped successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reset_database(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||
warn!("Resetting database...");
|
||||
Self::drop_all_tables(conn, sqlite).await?;
|
||||
Self::run(
|
||||
conn,
|
||||
if sqlite {
|
||||
DatabaseType::SQLite
|
||||
} else {
|
||||
DatabaseType::PostgreSQL
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
info!("Database reset completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_migration(
|
||||
conn: &impl ConnectionTrait,
|
||||
db_type: DatabaseType,
|
||||
) -> Result<(), DbErr> {
|
||||
Migration::run(conn, db_type).await
|
||||
}
|
||||
|
||||
pub async fn check_migration_status(
|
||||
conn: &impl ConnectionTrait,
|
||||
db_type: DatabaseType,
|
||||
) -> Result<MigrationStatus, DbErr> {
|
||||
let sqlite = db_type == DatabaseType::SQLite;
|
||||
|
||||
let tables = vec![
|
||||
TABLE_STUDENTS,
|
||||
TABLE_REASONS,
|
||||
TABLE_SCORE_EVENTS,
|
||||
TABLE_SETTLEMENTS,
|
||||
TABLE_SETTINGS,
|
||||
TABLE_TAGS,
|
||||
TABLE_STUDENT_TAGS,
|
||||
];
|
||||
|
||||
let mut existing_tables = Vec::new();
|
||||
let mut missing_tables = Vec::new();
|
||||
|
||||
for table in tables {
|
||||
if Migration::check_table_exists(conn, table, sqlite).await? {
|
||||
existing_tables.push(table.to_string());
|
||||
} else {
|
||||
missing_tables.push(table.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MigrationStatus {
|
||||
is_complete: missing_tables.is_empty(),
|
||||
existing_tables,
|
||||
missing_tables,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MigrationStatus {
|
||||
pub is_complete: bool,
|
||||
pub existing_tables: Vec<String>,
|
||||
pub missing_tables: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod connection;
|
||||
pub mod entities;
|
||||
pub mod migration;
|
||||
pub mod schema;
|
||||
|
||||
pub use connection::{
|
||||
create_postgres_connection, create_sqlite_connection, test_postgres_connection,
|
||||
test_sqlite_connection, ConnectionManager, DatabaseConfig, DatabaseType,
|
||||
};
|
||||
|
||||
pub use migration::{check_migration_status, run_migration, Migration, MigrationStatus};
|
||||
|
||||
pub use schema::{
|
||||
get_create_index_reasons_content_sql, get_create_index_score_events_settlement_id_sql,
|
||||
get_create_index_score_events_student_name_sql, get_create_reasons_table_sql,
|
||||
get_create_score_events_table_sql, get_create_settings_table_sql,
|
||||
get_create_settlements_table_sql, get_create_student_tags_table_sql,
|
||||
get_create_students_table_sql, get_create_tags_table_sql, reasons, score_events, settings,
|
||||
settlements, student_tags, students, tags, TABLE_REASONS, TABLE_SCORE_EVENTS, TABLE_SETTINGS,
|
||||
TABLE_SETTLEMENTS, TABLE_STUDENTS, TABLE_STUDENT_TAGS, TABLE_TAGS,
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
use crate::models::{ScoreEvent, CreateScoreEvent};
|
||||
use sqlx::{SqlitePool, Postgres, Sqlite};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct EventRepository {
|
||||
sqlite_pool: Option<SqlitePool>,
|
||||
postgres_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl EventRepository {
|
||||
pub fn new_sqlite(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: Some(pool),
|
||||
postgres_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_postgres(pool: PgPool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: None,
|
||||
postgres_pool: Some(pool),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_postgres(&self) -> bool {
|
||||
self.postgres_pool.is_some()
|
||||
}
|
||||
|
||||
pub async fn find_all(&self, limit: i32) -> Result<Vec<ScoreEvent>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE settlement_id IS NULL
|
||||
ORDER BY event_time DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE settlement_id IS NULL
|
||||
ORDER BY event_time DESC
|
||||
LIMIT $1"#
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, event: CreateScoreEvent) -> Result<i32, sqlx::Error> {
|
||||
let student_name = event.student_name.trim();
|
||||
let reason_content = event.reason_content.trim();
|
||||
let delta = event.delta;
|
||||
let uuid = Uuid::new_v4().to_string();
|
||||
let event_time = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let student: Option<(i32, i32)> = sqlx::query_as(
|
||||
"SELECT id, score FROM students WHERE name = ?"
|
||||
)
|
||||
.bind(student_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let student = student.ok_or_else(|| sqlx::Error::RowNotFound)?;
|
||||
let val_prev = student.1;
|
||||
let val_curr = val_prev + delta;
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL)"#
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(student_name)
|
||||
.bind(reason_content)
|
||||
.bind(delta)
|
||||
.bind(val_prev)
|
||||
.bind(val_curr)
|
||||
.bind(&event_time)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let event_id = result.last_insert_rowid() as i32;
|
||||
|
||||
sqlx::query("UPDATE students SET score = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(val_curr)
|
||||
.bind(&now)
|
||||
.bind(student.0)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(event_id)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let student: Option<(i32, i32)> = sqlx::query_as(
|
||||
"SELECT id, score FROM students WHERE name = $1"
|
||||
)
|
||||
.bind(student_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let student = student.ok_or_else(|| sqlx::Error::RowNotFound)?;
|
||||
let val_prev = student.1;
|
||||
let val_curr = val_prev + delta;
|
||||
|
||||
let event_id = sqlx::query_scalar::<Postgres, i32>(
|
||||
r#"INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
|
||||
RETURNING id"#
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(student_name)
|
||||
.bind(reason_content)
|
||||
.bind(delta)
|
||||
.bind(val_prev)
|
||||
.bind(val_curr)
|
||||
.bind(&event_time)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE students SET score = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(val_curr)
|
||||
.bind(&now)
|
||||
.bind(student.0)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(event_id)
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_by_uuid(&self, uuid: &str) -> Result<(), EventError> {
|
||||
let uuid = uuid.trim();
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let event: Option<ScoreEvent> = sqlx::query_as(
|
||||
"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id FROM score_events WHERE uuid = ?"
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let event = event.ok_or(EventError::NotFound)?;
|
||||
|
||||
if event.settlement_id.is_some() {
|
||||
return Err(EventError::AlreadySettled);
|
||||
}
|
||||
|
||||
let student: Option<(i32, i32)> = sqlx::query_as(
|
||||
"SELECT id, score FROM students WHERE name = ?"
|
||||
)
|
||||
.bind(&event.student_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(student) = student {
|
||||
let new_score = student.1 - event.delta;
|
||||
sqlx::query("UPDATE students SET score = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(new_score)
|
||||
.bind(&now)
|
||||
.bind(student.0)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM score_events WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let event: Option<ScoreEvent> = sqlx::query_as(
|
||||
"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id FROM score_events WHERE uuid = $1"
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let event = event.ok_or(EventError::NotFound)?;
|
||||
|
||||
if event.settlement_id.is_some() {
|
||||
return Err(EventError::AlreadySettled);
|
||||
}
|
||||
|
||||
let student: Option<(i32, i32)> = sqlx::query_as(
|
||||
"SELECT id, score FROM students WHERE name = $1"
|
||||
)
|
||||
.bind(&event.student_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(student) = student {
|
||||
let new_score = student.1 - event.delta;
|
||||
sqlx::query("UPDATE students SET score = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(new_score)
|
||||
.bind(&now)
|
||||
.bind(student.0)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM score_events WHERE uuid = $1")
|
||||
.bind(uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EventError::DatabaseError(sqlx::Error::PoolTimedOut))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_by_student(
|
||||
&self,
|
||||
student_name: &str,
|
||||
start_time: Option<&str>,
|
||||
limit: i32,
|
||||
) -> Result<Vec<ScoreEvent>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
if let Some(start) = start_time {
|
||||
sqlx::query_as::<Sqlite, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE student_name = ? AND settlement_id IS NULL AND julianday(event_time) >= julianday(?)
|
||||
ORDER BY event_time DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(student_name)
|
||||
.bind(start)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<Sqlite, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE student_name = ? AND settlement_id IS NULL
|
||||
ORDER BY event_time DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(student_name)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
if let Some(start) = start_time {
|
||||
sqlx::query_as::<Postgres, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE student_name = $1 AND settlement_id IS NULL AND event_time >= $2
|
||||
ORDER BY event_time DESC
|
||||
LIMIT $3"#
|
||||
)
|
||||
.bind(student_name)
|
||||
.bind(start)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<Postgres, ScoreEvent>(
|
||||
r#"SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id
|
||||
FROM score_events
|
||||
WHERE student_name = $1 AND settlement_id IS NULL
|
||||
ORDER BY event_time DESC
|
||||
LIMIT $2"#
|
||||
)
|
||||
.bind(student_name)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_leaderboard(&self, range: &str) -> Result<LeaderboardResult, sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
let start = match range {
|
||||
"today" => {
|
||||
let mut s = now;
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
"week" => {
|
||||
let mut s = now;
|
||||
let day = s.weekday().num_days_from_monday() as i64;
|
||||
s = s - chrono::Duration::days(day);
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
"month" => {
|
||||
let s = now.with_day0(0).unwrap_or(now);
|
||||
let mut s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
let mut s = now;
|
||||
s = s.with_hour(0).unwrap_or(s);
|
||||
s = s.with_minute(0).unwrap_or(s);
|
||||
s = s.with_second(0).unwrap_or(s);
|
||||
s = s.with_nanosecond(0).unwrap_or(s);
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
let start_time = start.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let rows = sqlx::query_as::<Sqlite, LeaderboardRow>(
|
||||
r#"SELECT s.id, s.name, s.score, COALESCE(SUM(e.delta), 0) as range_change
|
||||
FROM students s
|
||||
LEFT JOIN score_events e ON e.student_name = s.name
|
||||
AND e.settlement_id IS NULL
|
||||
AND julianday(e.event_time) >= julianday(?)
|
||||
GROUP BY s.id, s.name, s.score
|
||||
ORDER BY s.score DESC, range_change DESC, s.name ASC"#
|
||||
)
|
||||
.bind(&start_time)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(LeaderboardResult { start_time, rows })
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let rows = sqlx::query_as::<Postgres, LeaderboardRow>(
|
||||
r#"SELECT s.id, s.name, s.score, COALESCE(SUM(e.delta), 0) as range_change
|
||||
FROM students s
|
||||
LEFT JOIN score_events e ON e.student_name = s.name
|
||||
AND e.settlement_id IS NULL
|
||||
AND e.event_time >= $1
|
||||
GROUP BY s.id, s.name, s.score
|
||||
ORDER BY s.score DESC, range_change DESC, s.name ASC"#
|
||||
)
|
||||
.bind(&start_time)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(LeaderboardResult { start_time, rows })
|
||||
} else {
|
||||
Ok(LeaderboardResult {
|
||||
start_time,
|
||||
rows: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_last_score_time_by_students(
|
||||
&self,
|
||||
student_names: &[String],
|
||||
) -> Result<std::collections::HashMap<String, String>, sqlx::Error> {
|
||||
if student_names.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let placeholders: Vec<String> = student_names.iter().map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"SELECT student_name, MAX(event_time) as last_time FROM score_events WHERE student_name IN ({}) GROUP BY student_name",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<Sqlite, (String, String)>(&sql);
|
||||
for name in student_names {
|
||||
query = query.bind(name);
|
||||
}
|
||||
|
||||
let results = query.fetch_all(pool).await?;
|
||||
|
||||
Ok(results.into_iter().collect())
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let placeholders: Vec<String> = student_names.iter().enumerate().map(|(i, _)| format!("${}", i + 1)).collect();
|
||||
let sql = format!(
|
||||
"SELECT student_name, MAX(event_time) as last_time FROM score_events WHERE student_name IN ({}) GROUP BY student_name",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<Postgres, (String, String)>(&sql);
|
||||
for name in student_names {
|
||||
query = query.bind(name);
|
||||
}
|
||||
|
||||
let results = query.fetch_all(pool).await?;
|
||||
|
||||
Ok(results.into_iter().collect())
|
||||
} else {
|
||||
Ok(std::collections::HashMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn count_unsettled(&self) -> Result<i64, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_all_as_settled(&self, settlement_id: i32) -> Result<(), sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query("UPDATE score_events SET settlement_id = ? WHERE settlement_id IS NULL")
|
||||
.bind(settlement_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query("UPDATE score_events SET settlement_id = $1 WHERE settlement_id IS NULL")
|
||||
.bind(settlement_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_min_event_time(&self) -> Result<Option<String>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_scalar("SELECT MIN(event_time) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_scalar("SELECT MIN(event_time) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LeaderboardRow {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub range_change: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LeaderboardResult {
|
||||
pub start_time: String,
|
||||
pub rows: Vec<LeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EventError {
|
||||
NotFound,
|
||||
AlreadySettled,
|
||||
DatabaseError(sqlx::Error),
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for EventError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
EventError::DatabaseError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EventError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
EventError::NotFound => write!(f, "Event not found"),
|
||||
EventError::AlreadySettled => write!(f, "该记录已结算,无法撤销"),
|
||||
EventError::DatabaseError(e) => write!(f, "Database error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EventError {}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod student_repo;
|
||||
mod event_repo;
|
||||
mod reason_repo;
|
||||
mod settlement_repo;
|
||||
mod tag_repo;
|
||||
|
||||
pub use student_repo::StudentRepository;
|
||||
pub use event_repo::EventRepository;
|
||||
pub use reason_repo::ReasonRepository;
|
||||
pub use settlement_repo::SettlementRepository;
|
||||
pub use tag_repo::TagRepository;
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::models::{Reason, CreateReason, UpdateReason};
|
||||
use sqlx::{SqlitePool, Postgres, Sqlite, QueryBuilder};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct ReasonRepository {
|
||||
sqlite_pool: Option<SqlitePool>,
|
||||
postgres_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl ReasonRepository {
|
||||
pub fn new_sqlite(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: Some(pool),
|
||||
postgres_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_postgres(pool: PgPool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: None,
|
||||
postgres_pool: Some(pool),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> Result<Vec<Reason>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Reason>(
|
||||
r#"SELECT id, content, category, delta, is_system, updated_at
|
||||
FROM reasons
|
||||
ORDER BY category ASC, content ASC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Reason>(
|
||||
r#"SELECT id, content, category, delta, is_system, updated_at
|
||||
FROM reasons
|
||||
ORDER BY category ASC, content ASC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, reason: CreateReason) -> Result<i32, sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let content = reason.content.trim();
|
||||
let category = reason.category.trim();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO reasons (content, category, delta, is_system, updated_at)
|
||||
VALUES (?, ?, ?, 0, ?)"#
|
||||
)
|
||||
.bind(content)
|
||||
.bind(category)
|
||||
.bind(reason.delta)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid() as i32)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let result = sqlx::query_scalar::<Postgres, i32>(
|
||||
r#"INSERT INTO reasons (content, category, delta, is_system, updated_at)
|
||||
VALUES ($1, $2, $3, 0, $4)
|
||||
RETURNING id"#
|
||||
)
|
||||
.bind(content)
|
||||
.bind(category)
|
||||
.bind(reason.delta)
|
||||
.bind(&now)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: i32, reason: UpdateReason) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut query = QueryBuilder::new("UPDATE reasons SET updated_at = ");
|
||||
query.push_bind(&now);
|
||||
|
||||
if let Some(content) = &reason.content {
|
||||
query.push(", content = ");
|
||||
query.push_bind(content);
|
||||
}
|
||||
if let Some(category) = &reason.category {
|
||||
query.push(", category = ");
|
||||
query.push_bind(category);
|
||||
}
|
||||
if let Some(delta) = reason.delta {
|
||||
query.push(", delta = ");
|
||||
query.push_bind(delta);
|
||||
}
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(id);
|
||||
|
||||
query.build().execute(pool).await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut query = QueryBuilder::new("UPDATE reasons SET updated_at = ");
|
||||
query.push_bind(&now);
|
||||
|
||||
if let Some(content) = &reason.content {
|
||||
query.push(", content = ");
|
||||
query.push_bind(content);
|
||||
}
|
||||
if let Some(category) = &reason.category {
|
||||
query.push(", category = ");
|
||||
query.push_bind(category);
|
||||
}
|
||||
if let Some(delta) = reason.delta {
|
||||
query.push(", delta = ");
|
||||
query.push_bind(delta);
|
||||
}
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(id);
|
||||
|
||||
query.build().execute(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<u64, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let result = sqlx::query("DELETE FROM reasons WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let result = sqlx::query("DELETE FROM reasons WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<Option<Reason>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Reason>(
|
||||
"SELECT id, content, category, delta, is_system, updated_at FROM reasons WHERE id = ?"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Reason>(
|
||||
"SELECT id, content, category, delta, is_system, updated_at FROM reasons WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use crate::models::{Settlement, SettlementSummary, SettlementResult, SettlementLeaderboardRow};
|
||||
use sqlx::{SqlitePool, Postgres, Sqlite};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct SettlementRepository {
|
||||
sqlite_pool: Option<SqlitePool>,
|
||||
postgres_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl SettlementRepository {
|
||||
pub fn new_sqlite(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: Some(pool),
|
||||
postgres_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_postgres(pool: PgPool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: None,
|
||||
postgres_pool: Some(pool),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_postgres(&self) -> bool {
|
||||
self.postgres_pool.is_some()
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> Result<Vec<SettlementSummary>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let rows = sqlx::query_as::<Sqlite, SettlementSummary>(
|
||||
r#"SELECT s.id, s.start_time, s.end_time,
|
||||
(SELECT COUNT(1) FROM score_events e WHERE e.settlement_id = s.id) as event_count
|
||||
FROM settlements s
|
||||
ORDER BY julianday(s.end_time) DESC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let rows = sqlx::query_as::<Postgres, SettlementSummary>(
|
||||
r#"SELECT s.id, s.start_time, s.end_time,
|
||||
(SELECT COUNT(1) FROM score_events e WHERE e.settlement_id = s.id) as event_count
|
||||
FROM settlements s
|
||||
ORDER BY s.end_time DESC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self) -> Result<SettlementResult, SettlementError> {
|
||||
let now = Utc::now();
|
||||
let end_time = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let created_at = end_time.clone();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let unsettled_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if unsettled_count <= 0 {
|
||||
return Err(SettlementError::NoEventsToSettle);
|
||||
}
|
||||
|
||||
let last_settlement_end_time: Option<String> = sqlx::query_scalar(
|
||||
"SELECT end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let min_event_time: Option<String> = sqlx::query_scalar(
|
||||
"SELECT MIN(event_time) FROM score_events WHERE settlement_id IS NULL"
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let start_time = last_settlement_end_time
|
||||
.or(min_event_time)
|
||||
.unwrap_or_else(|| end_time.clone());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO settlements (start_time, end_time, created_at) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(&start_time)
|
||||
.bind(&end_time)
|
||||
.bind(&created_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let settlement_id = result.last_insert_rowid() as i32;
|
||||
|
||||
sqlx::query("UPDATE score_events SET settlement_id = ? WHERE settlement_id IS NULL")
|
||||
.bind(settlement_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE students SET score = 0, updated_at = ?")
|
||||
.bind(&end_time)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(SettlementResult {
|
||||
settlement_id,
|
||||
start_time,
|
||||
end_time,
|
||||
event_count: unsettled_count,
|
||||
})
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let unsettled_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM score_events WHERE settlement_id IS NULL")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if unsettled_count <= 0 {
|
||||
return Err(SettlementError::NoEventsToSettle);
|
||||
}
|
||||
|
||||
let last_settlement_end_time: Option<String> = sqlx::query_scalar(
|
||||
"SELECT end_time FROM settlements ORDER BY end_time DESC LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let min_event_time: Option<String> = sqlx::query_scalar(
|
||||
"SELECT MIN(event_time) FROM score_events WHERE settlement_id IS NULL"
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let start_time = last_settlement_end_time
|
||||
.or(min_event_time)
|
||||
.unwrap_or_else(|| end_time.clone());
|
||||
|
||||
let settlement_id = sqlx::query_scalar::<Postgres, i32>(
|
||||
"INSERT INTO settlements (start_time, end_time, created_at) VALUES ($1, $2, $3) RETURNING id"
|
||||
)
|
||||
.bind(&start_time)
|
||||
.bind(&end_time)
|
||||
.bind(&created_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE score_events SET settlement_id = $1 WHERE settlement_id IS NULL")
|
||||
.bind(settlement_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE students SET score = 0, updated_at = $1")
|
||||
.bind(&end_time)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(SettlementResult {
|
||||
settlement_id,
|
||||
start_time,
|
||||
end_time,
|
||||
event_count: unsettled_count,
|
||||
})
|
||||
} else {
|
||||
Err(SettlementError::DatabaseError(sqlx::Error::PoolTimedOut))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_leaderboard(&self, settlement_id: i32) -> Result<SettlementLeaderboard, SettlementError> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let settlement: Option<Settlement> = sqlx::query_as(
|
||||
"SELECT id, start_time, end_time, created_at FROM settlements WHERE id = ?"
|
||||
)
|
||||
.bind(settlement_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let settlement = settlement.ok_or(SettlementError::NotFound)?;
|
||||
|
||||
let rows = sqlx::query_as::<Sqlite, SettlementLeaderboardRow>(
|
||||
r#"SELECT student_name as name, COALESCE(SUM(delta), 0) as score
|
||||
FROM score_events
|
||||
WHERE settlement_id = ?
|
||||
GROUP BY student_name
|
||||
ORDER BY score DESC, name ASC"#
|
||||
)
|
||||
.bind(settlement_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(SettlementLeaderboard {
|
||||
settlement: SettlementInfo {
|
||||
id: settlement.id,
|
||||
start_time: settlement.start_time,
|
||||
end_time: settlement.end_time,
|
||||
},
|
||||
rows,
|
||||
})
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let settlement: Option<Settlement> = sqlx::query_as(
|
||||
"SELECT id, start_time, end_time, created_at FROM settlements WHERE id = $1"
|
||||
)
|
||||
.bind(settlement_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let settlement = settlement.ok_or(SettlementError::NotFound)?;
|
||||
|
||||
let rows = sqlx::query_as::<Postgres, SettlementLeaderboardRow>(
|
||||
r#"SELECT student_name as name, COALESCE(SUM(delta), 0) as score
|
||||
FROM score_events
|
||||
WHERE settlement_id = $1
|
||||
GROUP BY student_name
|
||||
ORDER BY score DESC, name ASC"#
|
||||
)
|
||||
.bind(settlement_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(SettlementLeaderboard {
|
||||
settlement: SettlementInfo {
|
||||
id: settlement.id,
|
||||
start_time: settlement.start_time,
|
||||
end_time: settlement.end_time,
|
||||
},
|
||||
rows,
|
||||
})
|
||||
} else {
|
||||
Err(SettlementError::DatabaseError(sqlx::Error::PoolTimedOut))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<Option<Settlement>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Settlement>(
|
||||
"SELECT id, start_time, end_time, created_at FROM settlements WHERE id = ?"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Settlement>(
|
||||
"SELECT id, start_time, end_time, created_at FROM settlements WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SettlementInfo {
|
||||
pub id: i32,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SettlementLeaderboard {
|
||||
pub settlement: SettlementInfo,
|
||||
pub rows: Vec<SettlementLeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SettlementError {
|
||||
NotFound,
|
||||
NoEventsToSettle,
|
||||
DatabaseError(sqlx::Error),
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for SettlementError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
SettlementError::DatabaseError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SettlementError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SettlementError::NotFound => write!(f, "结算记录不存在"),
|
||||
SettlementError::NoEventsToSettle => write!(f, "暂无可结算记录"),
|
||||
SettlementError::DatabaseError(e) => write!(f, "Database error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SettlementError {}
|
||||
@@ -0,0 +1,364 @@
|
||||
use crate::models::{Student, StudentUpdate, StudentWithTags};
|
||||
use sqlx::{SqlitePool, Postgres, Sqlite, QueryBuilder};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct StudentRepository {
|
||||
sqlite_pool: Option<SqlitePool>,
|
||||
postgres_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl StudentRepository {
|
||||
pub fn new_sqlite(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: Some(pool),
|
||||
postgres_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_postgres(pool: PgPool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: None,
|
||||
postgres_pool: Some(pool),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_postgres(&self) -> bool {
|
||||
self.postgres_pool.is_some()
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let students = sqlx::query_as::<Sqlite, Student>(
|
||||
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
|
||||
FROM students
|
||||
ORDER BY score DESC, name ASC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let students = sqlx::query_as::<Postgres, Student>(
|
||||
r#"SELECT id, name, score, tags, extra_json, created_at, updated_at
|
||||
FROM students
|
||||
ORDER BY score DESC, name ASC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, name: &str) -> Result<i32, sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let name = name.trim();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
|
||||
VALUES (?, 0, '[]', NULL, ?, ?)"#
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid() as i32)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let result = sqlx::query_scalar::<Postgres, i32>(
|
||||
r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at)
|
||||
VALUES ($1, 0, '[]', NULL, $2, $3)
|
||||
RETURNING id"#
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: i32, data: StudentUpdate) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut query = QueryBuilder::new("UPDATE students SET updated_at = ");
|
||||
query.push_bind(&now);
|
||||
|
||||
if let Some(name) = &data.name {
|
||||
query.push(", name = ");
|
||||
query.push_bind(name);
|
||||
}
|
||||
if let Some(score) = data.score {
|
||||
query.push(", score = ");
|
||||
query.push_bind(score);
|
||||
}
|
||||
if let Some(tags) = &data.tags {
|
||||
query.push(", tags = ");
|
||||
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
||||
}
|
||||
if let Some(extra_json) = &data.extra_json {
|
||||
query.push(", extra_json = ");
|
||||
query.push_bind(extra_json);
|
||||
}
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(id);
|
||||
|
||||
query.build().execute(pool).await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut query = QueryBuilder::new("UPDATE students SET updated_at = ");
|
||||
query.push_bind(&now);
|
||||
|
||||
if let Some(name) = &data.name {
|
||||
query.push(", name = ");
|
||||
query.push_bind(name);
|
||||
}
|
||||
if let Some(score) = data.score {
|
||||
query.push(", score = ");
|
||||
query.push_bind(score);
|
||||
}
|
||||
if let Some(tags) = &data.tags {
|
||||
query.push(", tags = ");
|
||||
query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()));
|
||||
}
|
||||
if let Some(extra_json) = &data.extra_json {
|
||||
query.push(", extra_json = ");
|
||||
query.push_bind(extra_json);
|
||||
}
|
||||
|
||||
query.push(" WHERE id = ");
|
||||
query.push_bind(id);
|
||||
|
||||
query.build().execute(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let student = sqlx::query_as::<Sqlite, Student>(
|
||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(student) = student {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM score_events WHERE student_name = ?")
|
||||
.bind(&student.name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM students WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let student = sqlx::query_as::<Postgres, Student>(
|
||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(student) = student {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM score_events WHERE student_name = $1")
|
||||
.bind(&student.name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM students WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn import_roster(&self, names: Vec<String>) -> Result<ImportResult, sqlx::Error> {
|
||||
let cleaned: Vec<String> = names
|
||||
.into_iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty() && n.len() <= 64)
|
||||
.collect();
|
||||
|
||||
let unique_names: Vec<String> = cleaned
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if unique_names.is_empty() {
|
||||
return Ok(ImportResult {
|
||||
inserted: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let existing: Vec<String> = sqlx::query_scalar("SELECT name FROM students")
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let existing_set: std::collections::HashSet<String> = existing.into_iter().collect();
|
||||
|
||||
let to_insert: Vec<&String> = unique_names
|
||||
.iter()
|
||||
.filter(|n| !existing_set.contains(*n))
|
||||
.collect();
|
||||
|
||||
let inserted = to_insert.len();
|
||||
let skipped = unique_names.len() - inserted;
|
||||
|
||||
for name in &to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES (?, 0, '[]', NULL, ?, ?)"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ImportResult {
|
||||
inserted,
|
||||
skipped,
|
||||
total: unique_names.len(),
|
||||
})
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let existing: Vec<String> = sqlx::query_scalar("SELECT name FROM students")
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let existing_set: std::collections::HashSet<String> = existing.into_iter().collect();
|
||||
|
||||
let to_insert: Vec<&String> = unique_names
|
||||
.iter()
|
||||
.filter(|n| !existing_set.contains(*n))
|
||||
.collect();
|
||||
|
||||
let inserted = to_insert.len();
|
||||
let skipped = unique_names.len() - inserted;
|
||||
|
||||
for name in &to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES ($1, 0, '[]', NULL, $2, $3)"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ImportResult {
|
||||
inserted,
|
||||
skipped,
|
||||
total: unique_names.len(),
|
||||
})
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Student>(
|
||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Student>(
|
||||
"SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_score(&self, id: i32, score: i32) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query("UPDATE students SET score = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(score)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query("UPDATE students SET score = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(score)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reset_all_scores(&self) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query("UPDATE students SET score = 0, updated_at = ?")
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query("UPDATE students SET score = 0, updated_at = $1")
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub inserted: usize,
|
||||
pub skipped: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use crate::models::{Tag, StudentTag};
|
||||
use sqlx::{SqlitePool, Postgres, Sqlite};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct TagRepository {
|
||||
sqlite_pool: Option<SqlitePool>,
|
||||
postgres_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl TagRepository {
|
||||
pub fn new_sqlite(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: Some(pool),
|
||||
postgres_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_postgres(pool: PgPool) -> Self {
|
||||
Self {
|
||||
sqlite_pool: None,
|
||||
postgres_pool: Some(pool),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> Result<Vec<Tag>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags ORDER BY created_at ASC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags ORDER BY created_at ASC"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_name(&self, name: &str) -> Result<Option<Tag>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags WHERE name = ?"
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags WHERE name = $1"
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, name: &str) -> Result<Tag, sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Tag {
|
||||
id: result.last_insert_rowid() as i32,
|
||||
name: name.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let id = sqlx::query_scalar::<Postgres, i32>(
|
||||
"INSERT INTO tags (name, created_at, updated_at) VALUES ($1, $2, $3) RETURNING id"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Tag {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_or_create(&self, name: &str) -> Result<Tag, sqlx::Error> {
|
||||
if let Some(tag) = self.find_by_name(name).await? {
|
||||
return Ok(tag);
|
||||
}
|
||||
self.create(name).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<bool, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let result = sqlx::query("DELETE FROM tags WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let result = sqlx::query("DELETE FROM tags WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
} else {
|
||||
Err(sqlx::Error::PoolTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_student(&self, student_id: i32) -> Result<Vec<Tag>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Tag>(
|
||||
r#"SELECT t.id, t.name, t.created_at, t.updated_at
|
||||
FROM tags t
|
||||
INNER JOIN student_tags st ON t.id = st.tag_id
|
||||
WHERE st.student_id = ?
|
||||
ORDER BY st.created_at ASC"#
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Tag>(
|
||||
r#"SELECT t.id, t.name, t.created_at, t.updated_at
|
||||
FROM tags t
|
||||
INNER JOIN student_tags st ON t.id = st.tag_id
|
||||
WHERE st.student_id = $1
|
||||
ORDER BY st.created_at ASC"#
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_tag_to_student(&self, student_id: i32, tag_id: i32) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT id FROM student_tags WHERE student_id = ? AND tag_id = ?"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
sqlx::query(
|
||||
"INSERT INTO student_tags (student_id, tag_id, created_at) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT id FROM student_tags WHERE student_id = $1 AND tag_id = $2"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
sqlx::query(
|
||||
"INSERT INTO student_tags (student_id, tag_id, created_at) VALUES ($1, $2, $3)"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_tag_from_student(&self, student_id: i32, tag_id: i32) -> Result<(), sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query("DELETE FROM student_tags WHERE student_id = ? AND tag_id = ?")
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query("DELETE FROM student_tags WHERE student_id = $1 AND tag_id = $2")
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_student_tags(&self, student_id: i32, tag_ids: &[i32]) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM student_tags WHERE student_id = ?")
|
||||
.bind(student_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for tag_id in tag_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO student_tags (student_id, tag_id, created_at) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM student_tags WHERE student_id = $1")
|
||||
.bind(student_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for tag_id in tag_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO student_tags (student_id, tag_id, created_at) VALUES ($1, $2, $3)"
|
||||
)
|
||||
.bind(student_id)
|
||||
.bind(tag_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<Option<Tag>, sqlx::Error> {
|
||||
if let Some(pool) = &self.sqlite_pool {
|
||||
sqlx::query_as::<Sqlite, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags WHERE id = ?"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Some(pool) = &self.postgres_pool {
|
||||
sqlx::query_as::<Postgres, Tag>(
|
||||
"SELECT id, name, created_at, updated_at FROM tags WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
pub const TABLE_STUDENTS: &str = "students";
|
||||
pub const TABLE_REASONS: &str = "reasons";
|
||||
pub const TABLE_SCORE_EVENTS: &str = "score_events";
|
||||
pub const TABLE_SETTLEMENTS: &str = "settlements";
|
||||
pub const TABLE_SETTINGS: &str = "settings";
|
||||
pub const TABLE_TAGS: &str = "tags";
|
||||
pub const TABLE_STUDENT_TAGS: &str = "student_tags";
|
||||
|
||||
pub mod students {
|
||||
pub const TABLE: &str = "students";
|
||||
pub const ID: &str = "id";
|
||||
pub const NAME: &str = "name";
|
||||
pub const TAGS: &str = "tags";
|
||||
pub const SCORE: &str = "score";
|
||||
pub const EXTRA_JSON: &str = "extra_json";
|
||||
pub const CREATED_AT: &str = "created_at";
|
||||
pub const UPDATED_AT: &str = "updated_at";
|
||||
}
|
||||
|
||||
pub mod reasons {
|
||||
pub const TABLE: &str = "reasons";
|
||||
pub const ID: &str = "id";
|
||||
pub const CONTENT: &str = "content";
|
||||
pub const CATEGORY: &str = "category";
|
||||
pub const DELTA: &str = "delta";
|
||||
pub const IS_SYSTEM: &str = "is_system";
|
||||
pub const UPDATED_AT: &str = "updated_at";
|
||||
}
|
||||
|
||||
pub mod score_events {
|
||||
pub const TABLE: &str = "score_events";
|
||||
pub const ID: &str = "id";
|
||||
pub const UUID: &str = "uuid";
|
||||
pub const STUDENT_NAME: &str = "student_name";
|
||||
pub const REASON_CONTENT: &str = "reason_content";
|
||||
pub const DELTA: &str = "delta";
|
||||
pub const VAL_PREV: &str = "val_prev";
|
||||
pub const VAL_CURR: &str = "val_curr";
|
||||
pub const EVENT_TIME: &str = "event_time";
|
||||
pub const SETTLEMENT_ID: &str = "settlement_id";
|
||||
}
|
||||
|
||||
pub mod settlements {
|
||||
pub const TABLE: &str = "settlements";
|
||||
pub const ID: &str = "id";
|
||||
pub const START_TIME: &str = "start_time";
|
||||
pub const END_TIME: &str = "end_time";
|
||||
pub const CREATED_AT: &str = "created_at";
|
||||
}
|
||||
|
||||
pub mod settings {
|
||||
pub const TABLE: &str = "settings";
|
||||
pub const KEY: &str = "key";
|
||||
pub const VALUE: &str = "value";
|
||||
}
|
||||
|
||||
pub mod tags {
|
||||
pub const TABLE: &str = "tags";
|
||||
pub const ID: &str = "id";
|
||||
pub const NAME: &str = "name";
|
||||
pub const CREATED_AT: &str = "created_at";
|
||||
pub const UPDATED_AT: &str = "updated_at";
|
||||
}
|
||||
|
||||
pub mod student_tags {
|
||||
pub const TABLE: &str = "student_tags";
|
||||
pub const ID: &str = "id";
|
||||
pub const STUDENT_ID: &str = "student_id";
|
||||
pub const TAG_ID: &str = "tag_id";
|
||||
pub const CREATED_AT: &str = "created_at";
|
||||
}
|
||||
|
||||
pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
tags TEXT DEFAULT '[]',
|
||||
score INTEGER DEFAULT 0,
|
||||
extra_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tags TEXT DEFAULT '[]',
|
||||
score INTEGER DEFAULT 0,
|
||||
extra_json TEXT,
|
||||
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_reasons_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS reasons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL UNIQUE,
|
||||
category TEXT DEFAULT '其他',
|
||||
delta INTEGER NOT NULL,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS reasons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL UNIQUE,
|
||||
category TEXT DEFAULT '其他',
|
||||
delta INTEGER NOT NULL,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_score_events_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS score_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
student_name TEXT NOT NULL,
|
||||
reason_content TEXT NOT NULL,
|
||||
delta INTEGER NOT NULL,
|
||||
val_prev INTEGER NOT NULL,
|
||||
val_curr INTEGER NOT NULL,
|
||||
event_time TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
settlement_id INTEGER
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS score_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
student_name TEXT NOT NULL,
|
||||
reason_content TEXT NOT NULL,
|
||||
delta INTEGER NOT NULL,
|
||||
val_prev INTEGER NOT NULL,
|
||||
val_curr INTEGER NOT NULL,
|
||||
event_time TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
settlement_id INTEGER
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_settlements_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS settlements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS settlements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_settings_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_tags_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_student_tags_table_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS student_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE (student_id, tag_id)
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
} else {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS student_tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE (student_id, tag_id)
|
||||
)
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_index_score_events_settlement_id_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
"CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)"
|
||||
.to_string()
|
||||
} else {
|
||||
"CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_index_score_events_student_name_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
"CREATE INDEX IF NOT EXISTS idx_score_events_student_name ON score_events(student_name)"
|
||||
.to_string()
|
||||
} else {
|
||||
"CREATE INDEX IF NOT EXISTS idx_score_events_student_name ON score_events(student_name)"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_create_index_reasons_content_sql(sqlite: bool) -> String {
|
||||
if sqlite {
|
||||
"CREATE INDEX IF NOT EXISTS idx_reasons_content ON reasons(content)".to_string()
|
||||
} else {
|
||||
"CREATE INDEX IF NOT EXISTS idx_reasons_content ON reasons(content)".to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
pub mod commands;
|
||||
pub mod db;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
pub mod utils;
|
||||
|
||||
use crate::db::connection::create_sqlite_connection;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
App, Manager, WindowEvent,
|
||||
};
|
||||
|
||||
pub fn setup_app(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
setup_database(app)?;
|
||||
|
||||
setup_tray(app)?;
|
||||
|
||||
setup_window_events(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let handle = app.handle();
|
||||
let db_path = if cfg!(debug_assertions) {
|
||||
std::path::PathBuf::from("data.sql")
|
||||
} else {
|
||||
let app_data_dir = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
let data_dir = app_data_dir.join("data");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
data_dir.join("data.sql")
|
||||
};
|
||||
|
||||
let db_path_str = db_path
|
||||
.to_str()
|
||||
.ok_or("Invalid database path")?
|
||||
.to_string();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match create_sqlite_connection(&db_path_str).await {
|
||||
Ok(conn) => {
|
||||
let state = handle.state::<crate::state::SafeAppState>();
|
||||
let mut state_guard = state.write();
|
||||
let mut db_guard = state_guard.db.write();
|
||||
*db_guard = Some(conn);
|
||||
eprintln!("Database connected to: {}", db_path_str);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to database: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_tray(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||
let hide_item = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_item, &hide_item, &quit_item])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(Image::from_bytes(include_bytes!("../icons/icon.png"))?)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"hide" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_window_events(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let window_clone = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window_clone.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use secscore::{commands::*, setup_app, state::AppState};
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
setup_app(app)?;
|
||||
let state = AppState::new(app.handle().clone());
|
||||
app.manage(Arc::new(RwLock::new(state)));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
student_query,
|
||||
student_create,
|
||||
student_update,
|
||||
student_delete,
|
||||
student_import_from_xlsx,
|
||||
tags_get_all,
|
||||
tags_get_by_student,
|
||||
tags_create,
|
||||
tags_delete,
|
||||
tags_update_student_tags,
|
||||
reason_query,
|
||||
reason_create,
|
||||
reason_update,
|
||||
reason_delete,
|
||||
event_query,
|
||||
event_create,
|
||||
event_delete,
|
||||
event_query_by_student,
|
||||
leaderboard_query,
|
||||
db_settlement_query,
|
||||
db_settlement_create,
|
||||
db_settlement_leaderboard,
|
||||
settings_get_all,
|
||||
settings_get,
|
||||
settings_set,
|
||||
auth_get_status,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
auth_set_passwords,
|
||||
auth_generate_recovery,
|
||||
auth_reset_by_recovery,
|
||||
auth_clear_all,
|
||||
theme_list,
|
||||
theme_current,
|
||||
theme_set,
|
||||
theme_save,
|
||||
theme_delete,
|
||||
auto_score_get_rules,
|
||||
auto_score_add_rule,
|
||||
auto_score_update_rule,
|
||||
auto_score_delete_rule,
|
||||
auto_score_toggle_rule,
|
||||
auto_score_get_status,
|
||||
auto_score_sort_rules,
|
||||
log_query,
|
||||
log_clear,
|
||||
log_set_level,
|
||||
log_write,
|
||||
data_export_json,
|
||||
data_import_json,
|
||||
window_minimize,
|
||||
window_maximize,
|
||||
window_close,
|
||||
window_is_maximized,
|
||||
toggle_devtools,
|
||||
window_resize,
|
||||
db_test_connection,
|
||||
db_switch_connection,
|
||||
db_get_status,
|
||||
db_sync,
|
||||
fs_get_config_structure,
|
||||
fs_read_json,
|
||||
fs_write_json,
|
||||
fs_read_text,
|
||||
fs_write_text,
|
||||
fs_delete_file,
|
||||
fs_list_files,
|
||||
fs_file_exists,
|
||||
http_server_start,
|
||||
http_server_stop,
|
||||
http_server_status,
|
||||
register_url_protocol,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mod reason;
|
||||
mod score_event;
|
||||
mod settlement;
|
||||
mod student;
|
||||
mod student_tag;
|
||||
mod tag;
|
||||
|
||||
pub use reason::*;
|
||||
pub use score_event::*;
|
||||
pub use settlement::*;
|
||||
pub use student::*;
|
||||
pub use student_tag::*;
|
||||
pub use tag::*;
|
||||
@@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Reason {
|
||||
pub id: i32,
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub delta: i32,
|
||||
pub is_system: i32,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateReason {
|
||||
pub content: String,
|
||||
pub category: String,
|
||||
pub delta: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateReason {
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub delta: Option<i32>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct ScoreEvent {
|
||||
pub id: i32,
|
||||
pub uuid: String,
|
||||
pub student_name: String,
|
||||
pub reason_content: String,
|
||||
pub delta: i32,
|
||||
pub val_prev: i32,
|
||||
pub val_curr: i32,
|
||||
pub event_time: String,
|
||||
pub settlement_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateScoreEvent {
|
||||
pub student_name: String,
|
||||
pub reason_content: String,
|
||||
pub delta: i32,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Settlement {
|
||||
pub id: i32,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementSummary {
|
||||
pub id: i32,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub event_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettlementResult {
|
||||
pub settlement_id: i32,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub event_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SettlementLeaderboardRow {
|
||||
pub name: String,
|
||||
pub score: i64,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Student {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub tags: String,
|
||||
pub extra_json: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StudentUpdate {
|
||||
pub name: Option<String>,
|
||||
pub score: Option<i32>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub extra_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StudentWithTags {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub score: i32,
|
||||
pub tags: Vec<String>,
|
||||
pub extra_json: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Student> for StudentWithTags {
|
||||
fn from(student: Student) -> Self {
|
||||
let tags = serde_json::from_str(&student.tags).unwrap_or_default();
|
||||
Self {
|
||||
id: student.id,
|
||||
name: student.name,
|
||||
score: student.score,
|
||||
tags,
|
||||
extra_json: student.extra_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct StudentTag {
|
||||
pub id: i32,
|
||||
pub student_id: i32,
|
||||
pub tag_id: i32,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Tag {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::permission::PermissionLevel;
|
||||
use super::security::SecurityService;
|
||||
use super::settings::SettingsService;
|
||||
|
||||
pub const SETTINGS_SECURITY_ADMIN: &str = "security_admin_password";
|
||||
pub const SETTINGS_SECURITY_POINTS: &str = "security_points_password";
|
||||
pub const SETTINGS_SECURITY_RECOVERY: &str = "security_recovery_string";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStatus {
|
||||
pub permission: String,
|
||||
pub has_admin_password: bool,
|
||||
pub has_points_password: bool,
|
||||
pub has_recovery_string: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub permission: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetPasswordsPayload {
|
||||
pub admin_password: Option<String>,
|
||||
pub points_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SetPasswordsResult {
|
||||
pub success: bool,
|
||||
pub recovery_string: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AuthService;
|
||||
|
||||
impl Default for AuthService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn get_status(
|
||||
settings: &SettingsService,
|
||||
sender_id: Option<u32>,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
) -> AuthStatus {
|
||||
let permission = if let Some(id) = sender_id {
|
||||
permissions.get_permission(id)
|
||||
} else {
|
||||
permissions.get_default_permission()
|
||||
};
|
||||
|
||||
AuthStatus {
|
||||
permission: permission.as_str().to_string(),
|
||||
has_admin_password: settings.has_secret(SETTINGS_SECURITY_ADMIN),
|
||||
has_points_password: settings.has_secret(SETTINGS_SECURITY_POINTS),
|
||||
has_recovery_string: settings.has_secret(SETTINGS_SECURITY_RECOVERY),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
settings: &mut SettingsService,
|
||||
security: &SecurityService,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
password: &str,
|
||||
iv_hex: &str,
|
||||
) -> LoginResult {
|
||||
if !SecurityService::is_six_digit(password) {
|
||||
permissions.set_permission(sender_id, permissions.get_default_permission());
|
||||
return LoginResult {
|
||||
success: false,
|
||||
permission: None,
|
||||
message: Some("Invalid password format".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let admin_cipher = settings.get_raw(SETTINGS_SECURITY_ADMIN);
|
||||
let points_cipher = settings.get_raw(SETTINGS_SECURITY_POINTS);
|
||||
|
||||
let admin_plain = security
|
||||
.decrypt_secret(&admin_cipher, iv_hex)
|
||||
.unwrap_or_default();
|
||||
let points_plain = security
|
||||
.decrypt_secret(&points_cipher, iv_hex)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !admin_cipher.is_empty() && admin_plain == password {
|
||||
permissions.set_permission(sender_id, PermissionLevel::Admin);
|
||||
return LoginResult {
|
||||
success: true,
|
||||
permission: Some("admin".to_string()),
|
||||
message: None,
|
||||
};
|
||||
}
|
||||
|
||||
if !points_cipher.is_empty() && points_plain == password {
|
||||
permissions.set_permission(sender_id, PermissionLevel::Points);
|
||||
return LoginResult {
|
||||
success: true,
|
||||
permission: Some("points".to_string()),
|
||||
message: None,
|
||||
};
|
||||
}
|
||||
|
||||
permissions.set_permission(sender_id, permissions.get_default_permission());
|
||||
LoginResult {
|
||||
success: false,
|
||||
permission: None,
|
||||
message: Some("Password incorrect".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
) -> PermissionLevel {
|
||||
let default = permissions.get_default_permission();
|
||||
permissions.set_permission(sender_id, default);
|
||||
default
|
||||
}
|
||||
|
||||
pub async fn set_passwords(
|
||||
settings: &mut SettingsService,
|
||||
security: &SecurityService,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
payload: SetPasswordsPayload,
|
||||
iv_hex: &str,
|
||||
) -> SetPasswordsResult {
|
||||
let has_admin = settings.has_secret(SETTINGS_SECURITY_ADMIN);
|
||||
if has_admin && !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some("Permission denied".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(admin_pwd) = payload.admin_password {
|
||||
let trimmed = admin_pwd.trim();
|
||||
if trimmed.is_empty() {
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_ADMIN, "").await;
|
||||
} else {
|
||||
if !SecurityService::is_six_digit(trimmed) {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some("Admin password must be 6 digits".to_string()),
|
||||
};
|
||||
}
|
||||
match security.encrypt_secret(trimmed, iv_hex) {
|
||||
Ok(encrypted) => {
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_ADMIN, &encrypted).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some(format!("Encryption failed: {}", e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(points_pwd) = payload.points_password {
|
||||
let trimmed = points_pwd.trim();
|
||||
if trimmed.is_empty() {
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_POINTS, "").await;
|
||||
} else {
|
||||
if !SecurityService::is_six_digit(trimmed) {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some("Points password must be 6 digits".to_string()),
|
||||
};
|
||||
}
|
||||
match security.encrypt_secret(trimmed, iv_hex) {
|
||||
Ok(encrypted) => {
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_POINTS, &encrypted).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some(format!("Encryption failed: {}", e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !settings.has_secret(SETTINGS_SECURITY_RECOVERY) {
|
||||
let recovery = SecurityService::generate_recovery_string();
|
||||
match security.encrypt_secret(&recovery, iv_hex) {
|
||||
Ok(encrypted) => {
|
||||
let _ = settings
|
||||
.set_raw(SETTINGS_SECURITY_RECOVERY, &encrypted)
|
||||
.await;
|
||||
return SetPasswordsResult {
|
||||
success: true,
|
||||
recovery_string: Some(recovery),
|
||||
message: None,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some(format!("Failed to encrypt recovery: {}", e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SetPasswordsResult {
|
||||
success: true,
|
||||
recovery_string: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_recovery(
|
||||
settings: &mut SettingsService,
|
||||
security: &SecurityService,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
iv_hex: &str,
|
||||
) -> SetPasswordsResult {
|
||||
if settings.has_secret(SETTINGS_SECURITY_ADMIN)
|
||||
&& !permissions.require_permission(sender_id, PermissionLevel::Admin)
|
||||
{
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some("Permission denied".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let recovery = SecurityService::generate_recovery_string();
|
||||
match security.encrypt_secret(&recovery, iv_hex) {
|
||||
Ok(encrypted) => {
|
||||
let _ = settings
|
||||
.set_raw(SETTINGS_SECURITY_RECOVERY, &encrypted)
|
||||
.await;
|
||||
SetPasswordsResult {
|
||||
success: true,
|
||||
recovery_string: Some(recovery),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
Err(e) => SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some(format!("Failed to encrypt recovery: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reset_by_recovery(
|
||||
settings: &mut SettingsService,
|
||||
security: &SecurityService,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
recovery_string: &str,
|
||||
iv_hex: &str,
|
||||
) -> SetPasswordsResult {
|
||||
let cipher = settings.get_raw(SETTINGS_SECURITY_RECOVERY);
|
||||
let plain = security.decrypt_secret(&cipher, iv_hex).unwrap_or_default();
|
||||
|
||||
if plain.is_empty() || plain != recovery_string.trim() {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some("Recovery string incorrect".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_ADMIN, "").await;
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_POINTS, "").await;
|
||||
|
||||
let new_recovery = SecurityService::generate_recovery_string();
|
||||
match security.encrypt_secret(&new_recovery, iv_hex) {
|
||||
Ok(encrypted) => {
|
||||
let _ = settings
|
||||
.set_raw(SETTINGS_SECURITY_RECOVERY, &encrypted)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
return SetPasswordsResult {
|
||||
success: false,
|
||||
recovery_string: None,
|
||||
message: Some(format!("Failed to encrypt new recovery: {}", e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
permissions.set_permission(sender_id, permissions.get_default_permission());
|
||||
SetPasswordsResult {
|
||||
success: true,
|
||||
recovery_string: Some(new_recovery),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_all(
|
||||
settings: &mut SettingsService,
|
||||
permissions: &mut super::permission::PermissionService,
|
||||
sender_id: u32,
|
||||
) -> Result<(), String> {
|
||||
if !permissions.require_permission(sender_id, PermissionLevel::Admin) {
|
||||
return Err("Permission denied".to_string());
|
||||
}
|
||||
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_ADMIN, "").await;
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_POINTS, "").await;
|
||||
let _ = settings.set_raw(SETTINGS_SECURITY_RECOVERY, "").await;
|
||||
permissions.set_permission(sender_id, permissions.get_default_permission());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoScoreTrigger {
|
||||
pub event: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoScoreAction {
|
||||
pub event: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoScoreRule {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "studentNames")]
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(rename = "lastExecuted")]
|
||||
pub last_executed: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AutoScoreRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
student_names: Vec::new(),
|
||||
triggers: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
last_executed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AutoScoreService {
|
||||
rules: Vec<AutoScoreRule>,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoScoreService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoScoreService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rules: Vec::new(),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(
|
||||
&mut self,
|
||||
_app_handle: &AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
self.initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_rules(&mut self, rules_json: serde_json::Value) {
|
||||
if let serde_json::Value::Array(arr) = rules_json {
|
||||
self.rules = arr
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value(v).ok())
|
||||
.collect();
|
||||
} else {
|
||||
self.rules = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_rules_json(&self) -> serde_json::Value {
|
||||
serde_json::to_value(&self.rules).unwrap_or(serde_json::Value::Array(vec![]))
|
||||
}
|
||||
|
||||
pub fn get_rules(&self) -> &[AutoScoreRule] {
|
||||
&self.rules
|
||||
}
|
||||
|
||||
pub fn get_rules_mut(&mut self) -> &mut Vec<AutoScoreRule> {
|
||||
&mut self.rules
|
||||
}
|
||||
|
||||
pub fn add_rule(&mut self, mut rule: AutoScoreRule) -> i32 {
|
||||
let new_id = self.rules.iter().map(|r| r.id).max().unwrap_or(0) + 1;
|
||||
rule.id = new_id;
|
||||
rule.last_executed = None;
|
||||
self.rules.push(rule);
|
||||
new_id
|
||||
}
|
||||
|
||||
pub fn update_rule(&mut self, rule: AutoScoreRule) -> bool {
|
||||
if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule.id) {
|
||||
*existing = rule;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_rule(&mut self, rule_id: i32) -> bool {
|
||||
let before_len = self.rules.len();
|
||||
self.rules.retain(|r| r.id != rule_id);
|
||||
self.rules.len() < before_len
|
||||
}
|
||||
|
||||
pub fn toggle_rule(&mut self, rule_id: i32, enabled: bool) -> bool {
|
||||
if let Some(rule) = self.rules.iter_mut().find(|r| r.id == rule_id) {
|
||||
rule.enabled = enabled;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_rules(&mut self, rule_ids: &[i32]) -> bool {
|
||||
let rule_map: HashMap<i32, AutoScoreRule> =
|
||||
self.rules.drain(..).map(|r| (r.id, r)).collect();
|
||||
|
||||
let mut sorted_rules: Vec<AutoScoreRule> = Vec::new();
|
||||
for id in rule_ids {
|
||||
if let Some(rule) = rule_map.get(id) {
|
||||
sorted_rules.push(rule.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (_, rule) in rule_map {
|
||||
if !rule_ids.contains(&rule.id) {
|
||||
sorted_rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
self.rules = sorted_rules;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.rules.iter().any(|r| r.enabled)
|
||||
}
|
||||
|
||||
pub fn get_rule_by_id(&self, id: i32) -> Option<&AutoScoreRule> {
|
||||
self.rules.iter().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
pub fn get_rule_by_id_mut(&mut self, id: i32) -> Option<&mut AutoScoreRule> {
|
||||
self.rules.iter_mut().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
pub fn mark_rule_executed(&mut self, rule_id: i32) {
|
||||
if let Some(rule) = self.rules.iter_mut().find(|r| r.id == rule_id) {
|
||||
rule.last_executed = Some(Utc::now().to_rfc3339());
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
let minutes = trigger
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(30);
|
||||
let interval_ms = minutes * 60 * 1000;
|
||||
|
||||
if let Some(last_executed_str) = &rule.last_executed {
|
||||
if let Ok(last_executed) = DateTime::parse_from_rfc3339(last_executed_str) {
|
||||
let last_executed_utc: DateTime<Utc> = last_executed.with_timezone(&Utc);
|
||||
let next_execute_time =
|
||||
last_executed_utc + chrono::Duration::milliseconds(interval_ms);
|
||||
let delay_ms = (next_execute_time - now).num_milliseconds();
|
||||
return Some(delay_ms.max(0));
|
||||
}
|
||||
}
|
||||
return Some(interval_ms);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn notify_rules_changed(&self, app_handle: &AppHandle) {
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportData {
|
||||
pub version: String,
|
||||
pub export_time: String,
|
||||
pub settings: serde_json::Value,
|
||||
pub students: Vec<StudentExport>,
|
||||
pub events: Vec<EventExport>,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StudentExport {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventExport {
|
||||
pub id: i32,
|
||||
pub student_id: i32,
|
||||
pub student_name: String,
|
||||
pub score_change: i32,
|
||||
pub reason: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub success: bool,
|
||||
pub message: Option<String>,
|
||||
pub students_imported: Option<usize>,
|
||||
pub events_imported: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct DataService {
|
||||
tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
impl Default for DataService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DataService {
|
||||
pub fn new() -> Self {
|
||||
Self { tags: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> &[Tag] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
pub fn find_tag_by_id(&self, id: i32) -> Option<&Tag> {
|
||||
self.tags.iter().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
pub fn find_tag_by_name(&self, name: &str) -> Option<&Tag> {
|
||||
self.tags.iter().find(|t| t.name == name)
|
||||
}
|
||||
|
||||
pub fn create_tag(&mut self, name: &str) -> Tag {
|
||||
let id = self.tags.iter().map(|t| t.id).max().unwrap_or(0) + 1;
|
||||
let tag = Tag {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
};
|
||||
self.tags.push(tag.clone());
|
||||
tag
|
||||
}
|
||||
|
||||
pub fn find_or_create_tag(&mut self, name: &str) -> Tag {
|
||||
if let Some(tag) = self.find_tag_by_name(name) {
|
||||
tag.clone()
|
||||
} else {
|
||||
self.create_tag(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_tag(&mut self, id: i32) -> bool {
|
||||
let before_len = self.tags.len();
|
||||
self.tags.retain(|t| t.id != id);
|
||||
self.tags.len() < before_len
|
||||
}
|
||||
|
||||
pub fn load_tags(&mut self, tags: Vec<Tag>) {
|
||||
self.tags = tags;
|
||||
}
|
||||
|
||||
pub fn export_json(
|
||||
&self,
|
||||
settings: serde_json::Value,
|
||||
students: Vec<StudentExport>,
|
||||
events: Vec<EventExport>,
|
||||
) -> ExportData {
|
||||
ExportData {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
export_time: chrono::Local::now().to_rfc3339(),
|
||||
settings,
|
||||
students,
|
||||
events,
|
||||
tags: self.tags.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_json(&mut self, json: &str) -> ImportResult {
|
||||
match serde_json::from_str::<ExportData>(json) {
|
||||
Ok(data) => {
|
||||
if !data.tags.is_empty() {
|
||||
self.tags = data.tags;
|
||||
}
|
||||
|
||||
ImportResult {
|
||||
success: true,
|
||||
message: Some("Import successful".to_string()),
|
||||
students_imported: Some(data.students.len()),
|
||||
events_imported: Some(data.events.len()),
|
||||
}
|
||||
}
|
||||
Err(e) => ImportResult {
|
||||
success: false,
|
||||
message: Some(format!("Failed to parse import data: {}", e)),
|
||||
students_imported: None,
|
||||
events_imported: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_import_data(json: &str) -> Result<ExportData, String> {
|
||||
serde_json::from_str(json).map_err(|e| format!("Invalid import data: {}", e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Debug => "debug",
|
||||
LogLevel::Info => "info",
|
||||
LogLevel::Warn => "warn",
|
||||
LogLevel::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"debug" => Some(LogLevel::Debug),
|
||||
"info" => Some(LogLevel::Info),
|
||||
"warn" => Some(LogLevel::Warn),
|
||||
"error" => Some(LogLevel::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
pub source: Option<String>,
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct LoggerService {
|
||||
log_dir: PathBuf,
|
||||
current_level: LogLevel,
|
||||
#[allow(dead_code)]
|
||||
max_files: usize,
|
||||
#[allow(dead_code)]
|
||||
max_size_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for LoggerService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoggerService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
log_dir: PathBuf::from("logs"),
|
||||
current_level: LogLevel::Info,
|
||||
max_files: 30,
|
||||
max_size_mb: 20,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self, _app_handle: &AppHandle) -> Result<(), String> {
|
||||
fs::create_dir_all(&self.log_dir).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_log_dir(&mut self, dir: PathBuf) {
|
||||
self.log_dir = dir;
|
||||
let _ = fs::create_dir_all(&self.log_dir);
|
||||
}
|
||||
|
||||
pub fn set_level(&mut self, level: LogLevel) {
|
||||
self.current_level = level;
|
||||
}
|
||||
|
||||
pub fn get_level(&self) -> LogLevel {
|
||||
self.current_level
|
||||
}
|
||||
|
||||
fn get_log_file_path(&self) -> PathBuf {
|
||||
let date = Local::now().format("%Y-%m-%d").to_string();
|
||||
self.log_dir.join(format!("secscore-{}.log", date))
|
||||
}
|
||||
|
||||
fn should_log(&self, level: LogLevel) -> bool {
|
||||
let current_rank = match self.current_level {
|
||||
LogLevel::Debug => 0,
|
||||
LogLevel::Info => 1,
|
||||
LogLevel::Warn => 2,
|
||||
LogLevel::Error => 3,
|
||||
};
|
||||
let level_rank = match level {
|
||||
LogLevel::Debug => 0,
|
||||
LogLevel::Info => 1,
|
||||
LogLevel::Warn => 2,
|
||||
LogLevel::Error => 3,
|
||||
};
|
||||
level_rank >= current_rank
|
||||
}
|
||||
|
||||
pub fn log(
|
||||
&self,
|
||||
level: LogLevel,
|
||||
message: &str,
|
||||
source: Option<&str>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) {
|
||||
if !self.should_log(level) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string();
|
||||
let entry = LogEntry {
|
||||
timestamp: timestamp.clone(),
|
||||
level: level.as_str().to_string(),
|
||||
message: message.to_string(),
|
||||
source: source.map(|s| s.to_string()),
|
||||
meta,
|
||||
};
|
||||
|
||||
let log_line = match serde_json::to_string(&entry) {
|
||||
Ok(s) => s,
|
||||
Err(_) => format!(
|
||||
r#"{{"timestamp":"{}","level":"{}","message":"{}"}}"#,
|
||||
timestamp,
|
||||
level.as_str(),
|
||||
message
|
||||
),
|
||||
};
|
||||
|
||||
if let Ok(file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.get_log_file_path())
|
||||
{
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
let _ = writeln!(writer, "{}", log_line);
|
||||
}
|
||||
|
||||
let console_output = format!(
|
||||
"{} {} {}{}",
|
||||
timestamp,
|
||||
level.as_str().to_uppercase(),
|
||||
message,
|
||||
if let Some(s) = source {
|
||||
format!(" [{}]", s)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
match level {
|
||||
LogLevel::Debug => tracing::debug!("{}", console_output),
|
||||
LogLevel::Info => tracing::info!("{}", console_output),
|
||||
LogLevel::Warn => tracing::warn!("{}", console_output),
|
||||
LogLevel::Error => tracing::error!("{}", console_output),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug(&self, message: &str) {
|
||||
self.log(LogLevel::Debug, message, None, None);
|
||||
}
|
||||
|
||||
pub fn info(&self, message: &str) {
|
||||
self.log(LogLevel::Info, message, None, None);
|
||||
}
|
||||
|
||||
pub fn warn(&self, message: &str) {
|
||||
self.log(LogLevel::Warn, message, None, None);
|
||||
}
|
||||
|
||||
pub fn error(&self, message: &str) {
|
||||
self.log(LogLevel::Error, message, None, None);
|
||||
}
|
||||
|
||||
pub fn debug_with_meta(&self, message: &str, meta: serde_json::Value) {
|
||||
self.log(LogLevel::Debug, message, None, Some(meta));
|
||||
}
|
||||
|
||||
pub fn info_with_meta(&self, message: &str, meta: serde_json::Value) {
|
||||
self.log(LogLevel::Info, message, None, Some(meta));
|
||||
}
|
||||
|
||||
pub fn warn_with_meta(&self, message: &str, meta: serde_json::Value) {
|
||||
self.log(LogLevel::Warn, message, None, Some(meta));
|
||||
}
|
||||
|
||||
pub fn error_with_meta(&self, message: &str, meta: serde_json::Value) {
|
||||
self.log(LogLevel::Error, message, None, Some(meta));
|
||||
}
|
||||
|
||||
pub fn read_logs(&self, lines: usize) -> Vec<String> {
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let files = self.get_log_files();
|
||||
|
||||
for file_path in files.into_iter().rev() {
|
||||
if result.len() >= lines {
|
||||
break;
|
||||
}
|
||||
if let Ok(file) = File::open(&file_path) {
|
||||
let reader = BufReader::new(file);
|
||||
let file_lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect();
|
||||
|
||||
for line in file_lines.into_iter().rev() {
|
||||
if result.len() >= lines {
|
||||
break;
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.reverse();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn clear_logs(&self) -> Result<(), String> {
|
||||
let files = self.get_log_files();
|
||||
for file_path in files {
|
||||
let _ = fs::remove_file(file_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_log_files(&self) -> Vec<PathBuf> {
|
||||
if !self.log_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut files: Vec<PathBuf> = match fs::read_dir(&self.log_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().map_or(false, |ext| ext == "log"))
|
||||
.map(|entry| entry.path())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
files.sort_by(|a, b| {
|
||||
let meta_a = fs::metadata(a).ok();
|
||||
let meta_b = fs::metadata(b).ok();
|
||||
match (meta_a, meta_b) {
|
||||
(Some(ma), Some(mb)) => ma
|
||||
.modified()
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
.cmp(&mb.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
pub async fn notify_level_changed(&self, app_handle: &AppHandle) {
|
||||
let _ = app_handle.emit("log:levelChanged", self.current_level.as_str());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
pub mod auth;
|
||||
pub mod auto_score;
|
||||
pub mod data;
|
||||
pub mod logger;
|
||||
pub mod permission;
|
||||
pub mod security;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
|
||||
pub use auth::AuthService;
|
||||
pub use auto_score::{AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger};
|
||||
pub use data::DataService;
|
||||
pub use logger::LoggerService;
|
||||
pub use permission::{PermissionLevel, PermissionService};
|
||||
pub use security::SecurityService;
|
||||
pub use settings::{SettingsKey, SettingsService, SettingsSpec, SettingsValue};
|
||||
pub use theme::{ThemeConfig, ThemeService};
|
||||
@@ -0,0 +1,105 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PermissionLevel {
|
||||
View,
|
||||
Points,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl PermissionLevel {
|
||||
pub fn rank(&self) -> u8 {
|
||||
match self {
|
||||
PermissionLevel::View => 0,
|
||||
PermissionLevel::Points => 1,
|
||||
PermissionLevel::Admin => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PermissionLevel::View => "view",
|
||||
PermissionLevel::Points => "points",
|
||||
PermissionLevel::Admin => "admin",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"view" => Some(PermissionLevel::View),
|
||||
"points" => Some(PermissionLevel::Points),
|
||||
"admin" => Some(PermissionLevel::Admin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const SETTINGS_SECURITY_ADMIN: &str = "security_admin_password";
|
||||
pub const SETTINGS_SECURITY_POINTS: &str = "security_points_password";
|
||||
|
||||
pub struct PermissionService {
|
||||
permissions_by_sender: HashMap<u32, PermissionLevel>,
|
||||
has_admin_password: bool,
|
||||
has_points_password: bool,
|
||||
}
|
||||
|
||||
impl Default for PermissionService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
permissions_by_sender: HashMap::new(),
|
||||
has_admin_password: false,
|
||||
has_points_password: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_password_status(&mut self, has_admin: bool, has_points: bool) {
|
||||
self.has_admin_password = has_admin;
|
||||
self.has_points_password = has_points;
|
||||
}
|
||||
|
||||
pub fn should_protect(&self) -> bool {
|
||||
self.has_admin_password || self.has_points_password
|
||||
}
|
||||
|
||||
pub fn get_default_permission(&self) -> PermissionLevel {
|
||||
if self.should_protect() {
|
||||
PermissionLevel::View
|
||||
} else {
|
||||
PermissionLevel::Admin
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_permission(&mut self, sender_id: u32) -> PermissionLevel {
|
||||
if let Some(&level) = self.permissions_by_sender.get(&sender_id) {
|
||||
level
|
||||
} else {
|
||||
let default = self.get_default_permission();
|
||||
self.permissions_by_sender.insert(sender_id, default);
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_permission(&mut self, sender_id: u32, level: PermissionLevel) {
|
||||
self.permissions_by_sender.insert(sender_id, level);
|
||||
}
|
||||
|
||||
pub fn require_permission(&mut self, sender_id: u32, required: PermissionLevel) -> bool {
|
||||
let current = self.get_permission(sender_id);
|
||||
current.rank() >= required.rank()
|
||||
}
|
||||
|
||||
pub fn clear_permission(&mut self, sender_id: u32) {
|
||||
self.permissions_by_sender.remove(&sender_id);
|
||||
}
|
||||
|
||||
pub fn clear_all_permissions(&mut self) {
|
||||
self.permissions_by_sender.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
const SALT: &[u8] = b"secscore-salt";
|
||||
const IV_KEY: &str = "security_crypto_iv";
|
||||
|
||||
pub struct SecurityService {
|
||||
app_data_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SecurityService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityService {
|
||||
pub fn new() -> Self {
|
||||
Self { app_data_dir: None }
|
||||
}
|
||||
|
||||
pub fn set_app_data_dir(&mut self, dir: &str) {
|
||||
self.app_data_dir = Some(dir.to_string());
|
||||
}
|
||||
|
||||
fn derive_key(&self) -> [u8; 32] {
|
||||
let data_dir = self.app_data_dir.as_deref().unwrap_or("secscore-default");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data_dir.as_bytes());
|
||||
hasher.update(SALT);
|
||||
let result = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&result[..32]);
|
||||
key
|
||||
}
|
||||
|
||||
fn generate_iv() -> [u8; 16] {
|
||||
use rand::RngCore;
|
||||
let mut iv = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut iv);
|
||||
iv
|
||||
}
|
||||
|
||||
fn iv_hex_to_bytes(hex: &str) -> Option<[u8; 16]> {
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn bytes_to_iv_hex(bytes: &[u8; 16]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
pub fn encrypt_secret(&self, plain_text: &str, iv_hex: &str) -> Result<String, String> {
|
||||
let iv = Self::iv_hex_to_bytes(iv_hex).ok_or("Invalid IV hex string")?;
|
||||
let key = self.derive_key();
|
||||
|
||||
let cipher = Aes256CbcEnc::new(&key.into(), &iv.into());
|
||||
let plaintext = plain_text.as_bytes();
|
||||
let buf_len = plaintext.len();
|
||||
let ciphertext_len = buf_len + 16 - (buf_len % 16);
|
||||
let mut buf = vec![0u8; ciphertext_len];
|
||||
buf[..buf_len].copy_from_slice(plaintext);
|
||||
let ciphertext = cipher
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buf, buf_len)
|
||||
.map_err(|_| "Padding error".to_string())?;
|
||||
|
||||
Ok(hex::encode(ciphertext))
|
||||
}
|
||||
|
||||
pub fn decrypt_secret(&self, cipher_text: &str, iv_hex: &str) -> Result<String, String> {
|
||||
if cipher_text.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let iv = Self::iv_hex_to_bytes(iv_hex).ok_or("Invalid IV hex string")?;
|
||||
let key = self.derive_key();
|
||||
|
||||
let mut ciphertext = hex::decode(cipher_text).map_err(|e| e.to_string())?;
|
||||
|
||||
let cipher = Aes256CbcDec::new(&key.into(), &iv.into());
|
||||
let plaintext = cipher
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut ciphertext)
|
||||
.map_err(|_| "Decryption failed".to_string())?;
|
||||
|
||||
String::from_utf8(plaintext.to_vec()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn is_six_digit(s: &str) -> bool {
|
||||
s.len() == 6 && s.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn generate_iv_hex() -> String {
|
||||
let iv = Self::generate_iv();
|
||||
Self::bytes_to_iv_hex(&iv)
|
||||
}
|
||||
|
||||
pub fn generate_recovery_string() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 18] = rng.gen();
|
||||
URL_SAFE_NO_PAD.encode(&bytes)
|
||||
}
|
||||
|
||||
pub fn get_iv_key() -> &'static str {
|
||||
IV_KEY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsSpec {
|
||||
pub is_wizard_completed: bool,
|
||||
pub log_level: String,
|
||||
pub window_zoom: f64,
|
||||
pub themes_custom: JsonValue,
|
||||
pub auto_score_enabled: bool,
|
||||
pub auto_score_rules: JsonValue,
|
||||
pub current_theme_id: String,
|
||||
pub pg_connection_string: String,
|
||||
pub pg_connection_status: JsonValue,
|
||||
}
|
||||
|
||||
impl Default for SettingsSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_wizard_completed: false,
|
||||
log_level: "info".to_string(),
|
||||
window_zoom: 1.0,
|
||||
themes_custom: JsonValue::Array(vec![]),
|
||||
auto_score_enabled: false,
|
||||
auto_score_rules: JsonValue::Array(vec![]),
|
||||
current_theme_id: "light-default".to_string(),
|
||||
pg_connection_string: String::new(),
|
||||
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SettingsKey {
|
||||
IsWizardCompleted,
|
||||
LogLevel,
|
||||
WindowZoom,
|
||||
ThemesCustom,
|
||||
AutoScoreEnabled,
|
||||
AutoScoreRules,
|
||||
CurrentThemeId,
|
||||
PgConnectionString,
|
||||
PgConnectionStatus,
|
||||
}
|
||||
|
||||
impl SettingsKey {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SettingsKey::IsWizardCompleted => "is_wizard_completed",
|
||||
SettingsKey::LogLevel => "log_level",
|
||||
SettingsKey::WindowZoom => "window_zoom",
|
||||
SettingsKey::ThemesCustom => "themes_custom",
|
||||
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
||||
SettingsKey::AutoScoreRules => "auto_score_rules",
|
||||
SettingsKey::CurrentThemeId => "current_theme_id",
|
||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"is_wizard_completed" => Some(SettingsKey::IsWizardCompleted),
|
||||
"log_level" => Some(SettingsKey::LogLevel),
|
||||
"window_zoom" => Some(SettingsKey::WindowZoom),
|
||||
"themes_custom" => Some(SettingsKey::ThemesCustom),
|
||||
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
||||
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
||||
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SettingValueKind {
|
||||
Boolean,
|
||||
String,
|
||||
Number,
|
||||
Json,
|
||||
}
|
||||
|
||||
pub struct SettingDefinition {
|
||||
pub kind: SettingValueKind,
|
||||
pub default_value: SettingsValue,
|
||||
pub write_permission: PermissionRequirement,
|
||||
pub validate: Option<fn(&SettingsValue) -> bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsValue {
|
||||
Boolean(bool),
|
||||
String(String),
|
||||
Number(f64),
|
||||
Json(JsonValue),
|
||||
}
|
||||
|
||||
impl SettingsValue {
|
||||
pub fn to_raw(&self) -> String {
|
||||
match self {
|
||||
SettingsValue::Boolean(b) => if *b { "1" } else { "0" }.to_string(),
|
||||
SettingsValue::String(s) => s.clone(),
|
||||
SettingsValue::Number(n) => n.to_string(),
|
||||
SettingsValue::Json(j) => j.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_raw(kind: SettingValueKind, raw: &str) -> Self {
|
||||
match kind {
|
||||
SettingValueKind::Boolean => {
|
||||
SettingsValue::Boolean(raw == "1" || raw.to_lowercase() == "true")
|
||||
}
|
||||
SettingValueKind::String => SettingsValue::String(raw.to_string()),
|
||||
SettingValueKind::Number => SettingsValue::Number(raw.parse().unwrap_or(0.0)),
|
||||
SettingValueKind::Json => {
|
||||
SettingsValue::Json(serde_json::from_str(raw).unwrap_or(JsonValue::Null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PermissionRequirement {
|
||||
Any,
|
||||
Admin,
|
||||
Points,
|
||||
View,
|
||||
}
|
||||
|
||||
pub struct SettingsService {
|
||||
cache: HashMap<String, String>,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: HashMap::new(),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<(), String> {
|
||||
if self.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_defaults();
|
||||
self.initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_definitions() -> HashMap<SettingsKey, SettingDefinition> {
|
||||
let mut defs = HashMap::new();
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::IsWizardCompleted,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Boolean,
|
||||
default_value: SettingsValue::Boolean(false),
|
||||
write_permission: PermissionRequirement::Any,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::LogLevel,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::String,
|
||||
default_value: SettingsValue::String("info".to_string()),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: Some(|v| {
|
||||
if let SettingsValue::String(s) = v {
|
||||
matches!(s.as_str(), "debug" | "info" | "warn" | "error")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::WindowZoom,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Number,
|
||||
default_value: SettingsValue::Number(1.0),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::ThemesCustom,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Json,
|
||||
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::AutoScoreEnabled,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Boolean,
|
||||
default_value: SettingsValue::Boolean(false),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::AutoScoreRules,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Json,
|
||||
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::CurrentThemeId,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::String,
|
||||
default_value: SettingsValue::String("light-default".to_string()),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::PgConnectionString,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::String,
|
||||
default_value: SettingsValue::String(String::new()),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::PgConnectionStatus,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Json,
|
||||
default_value: SettingsValue::Json(
|
||||
serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||
),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs
|
||||
}
|
||||
|
||||
fn ensure_defaults(&mut self) {
|
||||
let definitions = Self::get_definitions();
|
||||
for (key, def) in definitions.iter() {
|
||||
let key_str = key.as_str();
|
||||
if !self.cache.contains_key(key_str) {
|
||||
self.cache
|
||||
.insert(key_str.to_string(), def.default_value.to_raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_raw(&self, key: &str) -> String {
|
||||
self.cache.get(key).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn set_raw(&mut self, key: &str, value: &str) -> Result<(), String> {
|
||||
if let Some(settings_key) = SettingsKey::from_str(key) {
|
||||
let definitions = Self::get_definitions();
|
||||
if let Some(def) = definitions.get(&settings_key) {
|
||||
let parsed = SettingsValue::from_raw(def.kind, value);
|
||||
let validated = if let Some(validate_fn) = def.validate {
|
||||
if validate_fn(&parsed) {
|
||||
parsed.to_raw()
|
||||
} else {
|
||||
def.default_value.to_raw()
|
||||
}
|
||||
} else {
|
||||
parsed.to_raw()
|
||||
};
|
||||
self.cache.insert(key.to_string(), validated);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.cache.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_value(&self, key: SettingsKey) -> SettingsValue {
|
||||
let definitions = Self::get_definitions();
|
||||
let raw = self.cache.get(key.as_str()).cloned().unwrap_or_default();
|
||||
if let Some(def) = definitions.get(&key) {
|
||||
let parsed = SettingsValue::from_raw(def.kind, &raw);
|
||||
if let Some(validate_fn) = def.validate {
|
||||
if validate_fn(&parsed) {
|
||||
return parsed;
|
||||
} else {
|
||||
return def.default_value.clone();
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
SettingsValue::String(raw)
|
||||
}
|
||||
|
||||
pub async fn set_value(
|
||||
&mut self,
|
||||
key: SettingsKey,
|
||||
value: SettingsValue,
|
||||
) -> Result<(), String> {
|
||||
let definitions = Self::get_definitions();
|
||||
if let Some(def) = definitions.get(&key) {
|
||||
if let Some(validate_fn) = def.validate {
|
||||
if !validate_fn(&value) {
|
||||
return Err(format!("Invalid value for setting: {:?}", key));
|
||||
}
|
||||
}
|
||||
self.cache.insert(key.as_str().to_string(), value.to_raw());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all(&self) -> SettingsSpec {
|
||||
SettingsSpec {
|
||||
is_wizard_completed: match self.get_value(SettingsKey::IsWizardCompleted) {
|
||||
SettingsValue::Boolean(b) => b,
|
||||
_ => false,
|
||||
},
|
||||
log_level: match self.get_value(SettingsKey::LogLevel) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => "info".to_string(),
|
||||
},
|
||||
window_zoom: match self.get_value(SettingsKey::WindowZoom) {
|
||||
SettingsValue::Number(n) => n,
|
||||
_ => 1.0,
|
||||
},
|
||||
themes_custom: match self.get_value(SettingsKey::ThemesCustom) {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
},
|
||||
auto_score_enabled: match self.get_value(SettingsKey::AutoScoreEnabled) {
|
||||
SettingsValue::Boolean(b) => b,
|
||||
_ => false,
|
||||
},
|
||||
auto_score_rules: match self.get_value(SettingsKey::AutoScoreRules) {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => JsonValue::Array(vec![]),
|
||||
},
|
||||
current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => "light-default".to_string(),
|
||||
},
|
||||
pg_connection_string: match self.get_value(SettingsKey::PgConnectionString) {
|
||||
SettingsValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
},
|
||||
pg_connection_status: match self.get_value(SettingsKey::PgConnectionStatus) {
|
||||
SettingsValue::Json(j) => j,
|
||||
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_all_raw(&self) -> HashMap<String, String> {
|
||||
self.cache.clone()
|
||||
}
|
||||
|
||||
pub fn has_secret(&self, key: &str) -> bool {
|
||||
if let Some(v) = self.cache.get(key) {
|
||||
!v.trim().is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeConfig {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub mode: String,
|
||||
pub config: ThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeColors {
|
||||
#[serde(rename = "tdesign")]
|
||||
pub tdesign: HashMap<String, String>,
|
||||
#[serde(rename = "custom")]
|
||||
pub custom: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ThemeColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tdesign: HashMap::new(),
|
||||
custom: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_builtin_themes() -> Vec<ThemeConfig> {
|
||||
vec![
|
||||
ThemeConfig {
|
||||
name: "极简浅色".to_string(),
|
||||
id: "light-default".to_string(),
|
||||
mode: "light".to_string(),
|
||||
config: ThemeColors {
|
||||
tdesign: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("brandColor".to_string(), "#0052D9".to_string());
|
||||
m.insert("warningColor".to_string(), "#ED7B2F".to_string());
|
||||
m.insert("errorColor".to_string(), "#D54941".to_string());
|
||||
m.insert("successColor".to_string(), "#2BA471".to_string());
|
||||
m
|
||||
},
|
||||
custom: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"--ss-bg-color".to_string(),
|
||||
"linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)"
|
||||
.to_string(),
|
||||
);
|
||||
m.insert("--ss-card-bg".to_string(), "#ffffff".to_string());
|
||||
m.insert("--ss-text-main".to_string(), "#181818".to_string());
|
||||
m.insert("--ss-text-secondary".to_string(), "#666666".to_string());
|
||||
m.insert("--ss-border-color".to_string(), "#dcdcdc".to_string());
|
||||
m.insert(
|
||||
"--ss-header-bg".to_string(),
|
||||
"linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.7) 100%)"
|
||||
.to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-bg".to_string(),
|
||||
"rgba(255, 255, 255, 0.88)".to_string(),
|
||||
);
|
||||
m.insert("--ss-item-hover".to_string(), "#f3f3f3".to_string());
|
||||
m.insert("--ss-sidebar-text".to_string(), "#181818".to_string());
|
||||
m.insert(
|
||||
"--ss-sidebar-active-bg".to_string(),
|
||||
"rgba(0, 0, 0, 0.06)".to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-active-text".to_string(),
|
||||
"#181818".to_string(),
|
||||
);
|
||||
m
|
||||
},
|
||||
},
|
||||
},
|
||||
ThemeConfig {
|
||||
name: "极客深蓝".to_string(),
|
||||
id: "dark-default".to_string(),
|
||||
mode: "dark".to_string(),
|
||||
config: ThemeColors {
|
||||
tdesign: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("brandColor".to_string(), "#0052D9".to_string());
|
||||
m.insert("warningColor".to_string(), "#E37318".to_string());
|
||||
m.insert("errorColor".to_string(), "#D32029".to_string());
|
||||
m.insert("successColor".to_string(), "#248232".to_string());
|
||||
m
|
||||
},
|
||||
custom: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"--ss-bg-color".to_string(),
|
||||
"linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)"
|
||||
.to_string(),
|
||||
);
|
||||
m.insert("--ss-card-bg".to_string(), "#1e1e1e".to_string());
|
||||
m.insert("--ss-text-main".to_string(), "#ffffff".to_string());
|
||||
m.insert("--ss-text-secondary".to_string(), "#a0a0a0".to_string());
|
||||
m.insert("--ss-border-color".to_string(), "#333333".to_string());
|
||||
m.insert(
|
||||
"--ss-header-bg".to_string(),
|
||||
"rgba(30, 30, 30, 0.92)".to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-bg".to_string(),
|
||||
"rgba(30, 30, 30, 0.92)".to_string(),
|
||||
);
|
||||
m.insert("--ss-item-hover".to_string(), "#2c2c2c".to_string());
|
||||
m.insert("--ss-sidebar-text".to_string(), "#ffffff".to_string());
|
||||
m.insert(
|
||||
"--ss-sidebar-active-bg".to_string(),
|
||||
"rgba(255, 255, 255, 0.10)".to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-active-text".to_string(),
|
||||
"#ffffff".to_string(),
|
||||
);
|
||||
m
|
||||
},
|
||||
},
|
||||
},
|
||||
ThemeConfig {
|
||||
name: "冷静青蓝".to_string(),
|
||||
id: "dark-cyan".to_string(),
|
||||
mode: "dark".to_string(),
|
||||
config: ThemeColors {
|
||||
tdesign: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("brandColor".to_string(), "#16A085".to_string());
|
||||
m.insert("warningColor".to_string(), "#F39C12".to_string());
|
||||
m.insert("errorColor".to_string(), "#E74C3C".to_string());
|
||||
m.insert("successColor".to_string(), "#1ABC9C".to_string());
|
||||
m
|
||||
},
|
||||
custom: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"--ss-bg-color".to_string(),
|
||||
"linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)"
|
||||
.to_string(),
|
||||
);
|
||||
m.insert("--ss-card-bg".to_string(), "#0f1a23".to_string());
|
||||
m.insert("--ss-text-main".to_string(), "#E5F7FF".to_string());
|
||||
m.insert("--ss-text-secondary".to_string(), "#7FA4B8".to_string());
|
||||
m.insert("--ss-border-color".to_string(), "#1F3645".to_string());
|
||||
m.insert(
|
||||
"--ss-header-bg".to_string(),
|
||||
"rgba(15, 26, 35, 0.92)".to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-bg".to_string(),
|
||||
"rgba(15, 26, 35, 0.92)".to_string(),
|
||||
);
|
||||
m.insert("--ss-item-hover".to_string(), "#182635".to_string());
|
||||
m.insert("--ss-sidebar-text".to_string(), "#E5F7FF".to_string());
|
||||
m.insert("--ss-sidebar-active-bg".to_string(), "#182635".to_string());
|
||||
m.insert(
|
||||
"--ss-sidebar-active-text".to_string(),
|
||||
"#E5F7FF".to_string(),
|
||||
);
|
||||
m
|
||||
},
|
||||
},
|
||||
},
|
||||
ThemeConfig {
|
||||
name: "清新马卡龙".to_string(),
|
||||
id: "light-pastel".to_string(),
|
||||
mode: "light".to_string(),
|
||||
config: ThemeColors {
|
||||
tdesign: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("brandColor".to_string(), "#FF9AA2".to_string());
|
||||
m.insert("warningColor".to_string(), "#FFB347".to_string());
|
||||
m.insert("errorColor".to_string(), "#FF6F69".to_string());
|
||||
m.insert("successColor".to_string(), "#B5EAD7".to_string());
|
||||
m
|
||||
},
|
||||
custom: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"--ss-bg-color".to_string(),
|
||||
"linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)"
|
||||
.to_string(),
|
||||
);
|
||||
m.insert("--ss-card-bg".to_string(), "#ffffff".to_string());
|
||||
m.insert("--ss-text-main".to_string(), "#3A3A3A".to_string());
|
||||
m.insert("--ss-text-secondary".to_string(), "#8A8A8A".to_string());
|
||||
m.insert("--ss-border-color".to_string(), "#F1D3D3".to_string());
|
||||
m.insert(
|
||||
"--ss-header-bg".to_string(),
|
||||
"rgba(255, 255, 255, 0.88)".to_string(),
|
||||
);
|
||||
m.insert(
|
||||
"--ss-sidebar-bg".to_string(),
|
||||
"rgba(255, 255, 255, 0.90)".to_string(),
|
||||
);
|
||||
m.insert("--ss-item-hover".to_string(), "#FFE7E0".to_string());
|
||||
m.insert("--ss-sidebar-text".to_string(), "#3A3A3A".to_string());
|
||||
m.insert("--ss-sidebar-active-bg".to_string(), "#FFE7E0".to_string());
|
||||
m.insert(
|
||||
"--ss-sidebar-active-text".to_string(),
|
||||
"#3A3A3A".to_string(),
|
||||
);
|
||||
m
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub struct ThemeService {
|
||||
current_theme_id: String,
|
||||
custom_themes: Vec<ThemeConfig>,
|
||||
builtin_themes: Vec<ThemeConfig>,
|
||||
}
|
||||
|
||||
impl Default for ThemeService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ThemeService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_theme_id: "light-default".to_string(),
|
||||
custom_themes: Vec::new(),
|
||||
builtin_themes: create_builtin_themes(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self, _app_handle: &AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_saved_theme(&mut self, theme_id: &str) {
|
||||
let themes = self.get_theme_list();
|
||||
if themes.iter().any(|t| t.id == theme_id) {
|
||||
self.current_theme_id = theme_id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_custom_themes(&mut self, themes_json: serde_json::Value) {
|
||||
if let serde_json::Value::Array(arr) = themes_json {
|
||||
self.custom_themes = arr
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value(v).ok())
|
||||
.collect();
|
||||
} else {
|
||||
self.custom_themes = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_custom_themes_json(&self) -> serde_json::Value {
|
||||
serde_json::to_value(&self.custom_themes).unwrap_or(serde_json::Value::Array(vec![]))
|
||||
}
|
||||
|
||||
pub fn get_theme_list(&self) -> Vec<ThemeConfig> {
|
||||
let mut themes = self.builtin_themes.clone();
|
||||
themes.extend(self.custom_themes.clone());
|
||||
themes
|
||||
}
|
||||
|
||||
pub fn get_current_theme(&self) -> Option<ThemeConfig> {
|
||||
let themes = self.get_theme_list();
|
||||
themes.into_iter().find(|t| t.id == self.current_theme_id)
|
||||
}
|
||||
|
||||
pub fn get_current_theme_id(&self) -> &str {
|
||||
&self.current_theme_id
|
||||
}
|
||||
|
||||
pub fn set_current_theme(&mut self, theme_id: &str) -> bool {
|
||||
let themes = self.get_theme_list();
|
||||
if themes.iter().any(|t| t.id == theme_id) {
|
||||
self.current_theme_id = theme_id.to_string();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_theme(&mut self, theme: ThemeConfig) -> Result<(), String> {
|
||||
if theme.id.is_empty() || theme.name.is_empty() {
|
||||
return Err("Invalid theme".to_string());
|
||||
}
|
||||
|
||||
if self.builtin_themes.iter().any(|t| t.id == theme.id) {
|
||||
return Err("Cannot overwrite builtin themes".to_string());
|
||||
}
|
||||
|
||||
if let Some(idx) = self.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||
self.custom_themes[idx] = theme;
|
||||
} else {
|
||||
self.custom_themes.insert(0, theme);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_theme(&mut self, theme_id: &str) -> Result<(), String> {
|
||||
if self.builtin_themes.iter().any(|t| t.id == theme_id) {
|
||||
return Err("Cannot delete builtin themes".to_string());
|
||||
}
|
||||
|
||||
let before_len = self.custom_themes.len();
|
||||
self.custom_themes.retain(|t| t.id != theme_id);
|
||||
|
||||
if self.custom_themes.len() == before_len {
|
||||
return Err("Theme not found".to_string());
|
||||
}
|
||||
|
||||
if self.current_theme_id == theme_id {
|
||||
self.current_theme_id = "light-default".to_string();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn notify_theme_update(&self, app_handle: &AppHandle) {
|
||||
if let Some(theme) = self.get_current_theme() {
|
||||
let _ = app_handle.emit("theme:updated", &theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use parking_lot::RwLock;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::services::{
|
||||
auth::AuthService, auto_score::AutoScoreService, data::DataService, logger::LoggerService,
|
||||
permission::PermissionService, security::SecurityService, settings::SettingsService,
|
||||
theme::ThemeService,
|
||||
};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<RwLock<Option<DatabaseConnection>>>,
|
||||
pub settings: Arc<RwLock<SettingsService>>,
|
||||
pub security: Arc<RwLock<SecurityService>>,
|
||||
pub permissions: Arc<RwLock<PermissionService>>,
|
||||
pub auth: Arc<RwLock<AuthService>>,
|
||||
pub theme: Arc<RwLock<ThemeService>>,
|
||||
pub auto_score: Arc<RwLock<AutoScoreService>>,
|
||||
pub logger: Arc<RwLock<LoggerService>>,
|
||||
pub data: Arc<RwLock<DataService>>,
|
||||
pub app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
let settings = Arc::new(RwLock::new(SettingsService::new()));
|
||||
let security = Arc::new(RwLock::new(SecurityService::new()));
|
||||
let permissions = Arc::new(RwLock::new(PermissionService::new()));
|
||||
let auth = Arc::new(RwLock::new(AuthService::new()));
|
||||
let theme = Arc::new(RwLock::new(ThemeService::new()));
|
||||
let auto_score = Arc::new(RwLock::new(AutoScoreService::new()));
|
||||
let logger = Arc::new(RwLock::new(LoggerService::new()));
|
||||
let data = Arc::new(RwLock::new(DataService::new()));
|
||||
let db = Arc::new(RwLock::new(None));
|
||||
|
||||
Self {
|
||||
db,
|
||||
settings,
|
||||
security,
|
||||
permissions,
|
||||
auth,
|
||||
theme,
|
||||
auto_score,
|
||||
logger,
|
||||
data,
|
||||
app_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.settings.write().initialize().await?;
|
||||
self.logger.write().initialize(&self.app_handle).await?;
|
||||
self.theme.write().initialize(&self.app_handle).await?;
|
||||
self.auto_score.write().initialize(&self.app_handle).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type SafeAppState = Arc<RwLock<AppState>>;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "SecScore",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.secscore.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "SecScore",
|
||||
"width": 1180,
|
||||
"height": 680,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true,
|
||||
"menuOnLeftClick": false
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
},
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "13.0"
|
||||
},
|
||||
"android": {
|
||||
"minSdkVersion": 24
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user