先提交上去

This commit is contained in:
Fox_block
2026-03-16 20:49:46 +08:00
parent 86abcb5602
commit 192b48cab0
303 changed files with 30019 additions and 5846 deletions
+40
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+25
View File
@@ -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

+87
View File
@@ -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", &reg_command])
.output();
let _ = Command::new("cmd")
.args(["/C", &reg_command2])
.output();
let _ = Command::new("cmd")
.args(["/C", &reg_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"))
}
}
+255
View File
@@ -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)),
}
}
+318
View File
@@ -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))
}
+63
View File
@@ -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"),
))
}
}
+208
View File
@@ -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()),
}))
}
}
+398
View File
@@ -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) = &params.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"))
}
}
+286
View File
@@ -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()))
}
+138
View File
@@ -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))
}
+84
View File
@@ -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())
}
+35
View File
@@ -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::*;
+221
View File
@@ -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"))
}
}
+59
View File
@@ -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,
}
+150
View File
@@ -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(()))
}
+104
View File
@@ -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))
}
+336
View File
@@ -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"))
}
}
+278
View File
@@ -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"))
}
}
+173
View File
@@ -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(()))
}
+91
View File
@@ -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(())
}
+228
View File
@@ -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)
}
+11
View File
@@ -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;
+19
View File
@@ -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 {}
+22
View File
@@ -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 {}
+42
View File
@@ -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 {}
+29
View File
@@ -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 {}
+26
View File
@@ -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 {}
+333
View File
@@ -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>,
}
+21
View File
@@ -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,
};
+512
View File
@@ -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 {}
+11
View File
@@ -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,
}
+290
View File
@@ -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)
}
}
}
+289
View File
@@ -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()
}
}
+128
View File
@@ -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(())
}
+93
View File
@@ -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");
}
+13
View File
@@ -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::*;
+25
View File
@@ -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>,
}
+21
View File
@@ -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,
}
+31
View File
@@ -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,
}
+42
View File
@@ -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,
}
}
}
+9
View File
@@ -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,
}
+9
View File
@@ -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,
}
+332
View File
@@ -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(())
}
}
+199
View File
@@ -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);
}
}
+141
View File
@@ -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))
}
}
+267
View File
@@ -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());
}
}
+17
View File
@@ -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};
+105
View File
@@ -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();
}
}
+117
View File
@@ -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
}
}
+389
View File
@@ -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
}
}
}
+328
View File
@@ -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);
}
}
}
+60
View File
@@ -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>>;
+1
View File
@@ -0,0 +1 @@
+68
View File
@@ -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
}
}
}