mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
新增看板模块:支持多看板多SQL学生列表与预设模板
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Map, Value as JsonValue};
|
||||||
|
use sqlx::{Column, Row, SqlitePool};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||||
|
use crate::services::PermissionLevel;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::response::IpcResponse;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct BoardSqlQueryParams {
|
||||||
|
pub sql: String,
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum QueryBackend {
|
||||||
|
Sqlite,
|
||||||
|
Postgres,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let id = sender_id.unwrap_or(0);
|
||||||
|
permissions.require_permission(id, PermissionLevel::View)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_forbidden_keyword(sql: &str) -> bool {
|
||||||
|
let forbidden: HashSet<&str> = [
|
||||||
|
"insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum",
|
||||||
|
"grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze",
|
||||||
|
"merge", "call", "execute",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut token = String::new();
|
||||||
|
for ch in sql.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||||
|
token.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.is_empty() {
|
||||||
|
if forbidden.contains(token.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
token.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!token.is_empty() && forbidden.contains(token.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_readonly_query(sql: &str) -> bool {
|
||||||
|
let trimmed = sql.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.contains(';')
|
||||||
|
|| trimmed.contains("--")
|
||||||
|
|| trimmed.contains("/*")
|
||||||
|
|| trimmed.contains("*/")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if !(lower.starts_with("select ") || lower.starts_with("with ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
!contains_forbidden_keyword(&lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_limit(limit: Option<u64>) -> u64 {
|
||||||
|
match limit {
|
||||||
|
Some(v) if v > 0 => v.min(500),
|
||||||
|
_ => 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_db_path(app_handle: &AppHandle) -> Result<String, String> {
|
||||||
|
if cfg!(all(debug_assertions, desktop)) {
|
||||||
|
return Ok("data.sql".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_data_dir = app_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))?;
|
||||||
|
let db_path = data_dir.join("data.sql");
|
||||||
|
db_path
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "Invalid sqlite database path".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cell_sqlite(row: &sqlx::sqlite::SqliteRow, index: usize) -> JsonValue {
|
||||||
|
if let Ok(v) = row.try_get::<Option<i64>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<i32>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<f64>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<bool>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<String>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonValue::Null
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_json_sqlite(row: &sqlx::sqlite::SqliteRow) -> JsonValue {
|
||||||
|
let mut map = Map::new();
|
||||||
|
for (index, column) in row.columns().iter().enumerate() {
|
||||||
|
map.insert(column.name().to_string(), decode_cell_sqlite(row, index));
|
||||||
|
}
|
||||||
|
JsonValue::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cell_pg(row: &sqlx::postgres::PgRow, index: usize) -> JsonValue {
|
||||||
|
if let Ok(v) = row.try_get::<Option<i64>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<i32>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<f64>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<bool>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<String>, _>(index) {
|
||||||
|
return v.map(JsonValue::from).unwrap_or(JsonValue::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonValue::Null
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_json_pg(row: &sqlx::postgres::PgRow) -> JsonValue {
|
||||||
|
let mut map = Map::new();
|
||||||
|
for (index, column) in row.columns().iter().enumerate() {
|
||||||
|
map.insert(column.name().to_string(), decode_cell_pg(row, index));
|
||||||
|
}
|
||||||
|
JsonValue::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_sqlite(sql: &str, sqlite_path: &str) -> Result<Vec<JsonValue>, String> {
|
||||||
|
let sqlite_url = format!("sqlite://{}?mode=rwc", sqlite_path);
|
||||||
|
let pool = SqlitePool::connect(&sqlite_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to connect sqlite: {}", e))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(sql)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SQL query failed: {}", e))?;
|
||||||
|
|
||||||
|
let data = rows.iter().map(row_to_json_sqlite).collect();
|
||||||
|
pool.close().await;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_postgres(sql: &str, pg_url: &str) -> Result<Vec<JsonValue>, String> {
|
||||||
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(pg_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to connect PostgreSQL: {}", e))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(sql)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SQL query failed: {}", e))?;
|
||||||
|
|
||||||
|
let data = rows.iter().map(row_to_json_pg).collect();
|
||||||
|
pool.close().await;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn board_query_sql(
|
||||||
|
params: BoardSqlQueryParams,
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Vec<JsonValue>>, String> {
|
||||||
|
if !check_view_permission(&state, sender_id) {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: view required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_readonly_query(¶ms.sql) {
|
||||||
|
return Ok(IpcResponse::error(
|
||||||
|
"Only single read-only SELECT/CTE query is allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = parse_limit(params.limit);
|
||||||
|
let wrapped_sql = format!(
|
||||||
|
"SELECT * FROM ({}) AS ss_board_query LIMIT {}",
|
||||||
|
params.sql.trim(),
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
|
let (backend, sqlite_path, pg_url) = {
|
||||||
|
let state_guard = state.read();
|
||||||
|
let db_conn = state_guard.db.read().clone();
|
||||||
|
let app_handle = state_guard.app_handle.clone();
|
||||||
|
let mut settings = state_guard.settings.write();
|
||||||
|
|
||||||
|
settings.attach_db(db_conn);
|
||||||
|
settings.initialize().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let status = match settings.get_value(SettingsKey::PgConnectionStatus) {
|
||||||
|
SettingsValue::Json(v) => v,
|
||||||
|
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let connected = status
|
||||||
|
.get("connected")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let db_type = status
|
||||||
|
.get("type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("sqlite");
|
||||||
|
|
||||||
|
let backend = if connected && db_type == "postgresql" {
|
||||||
|
QueryBackend::Postgres
|
||||||
|
} else {
|
||||||
|
QueryBackend::Sqlite
|
||||||
|
};
|
||||||
|
|
||||||
|
let pg_url = match settings.get_value(SettingsKey::PgConnectionString) {
|
||||||
|
SettingsValue::String(s) => s,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sqlite_path = sqlite_db_path(&app_handle)?;
|
||||||
|
(backend, sqlite_path, pg_url)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = match backend {
|
||||||
|
QueryBackend::Postgres if !pg_url.trim().is_empty() => {
|
||||||
|
query_postgres(&wrapped_sql, &pg_url).await
|
||||||
|
}
|
||||||
|
_ => query_sqlite(&wrapped_sql, &sqlite_path).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(data) => Ok(IpcResponse::success(data)),
|
||||||
|
Err(err) => Ok(IpcResponse::error(&err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auto_score;
|
pub mod auto_score;
|
||||||
|
pub mod board;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
@@ -8,8 +9,8 @@ pub mod filesystem;
|
|||||||
pub mod http_server;
|
pub mod http_server;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod reason;
|
pub mod reason;
|
||||||
pub mod reward;
|
|
||||||
pub mod response;
|
pub mod response;
|
||||||
|
pub mod reward;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod settlement;
|
pub mod settlement;
|
||||||
pub mod student;
|
pub mod student;
|
||||||
@@ -20,6 +21,7 @@ pub mod window;
|
|||||||
pub use app::*;
|
pub use app::*;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use auto_score::*;
|
pub use auto_score::*;
|
||||||
|
pub use board::*;
|
||||||
pub use data::*;
|
pub use data::*;
|
||||||
pub use database::*;
|
pub use database::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -27,8 +29,8 @@ pub use filesystem::*;
|
|||||||
pub use http_server::*;
|
pub use http_server::*;
|
||||||
pub use log::*;
|
pub use log::*;
|
||||||
pub use reason::*;
|
pub use reason::*;
|
||||||
pub use reward::*;
|
|
||||||
pub use response::*;
|
pub use response::*;
|
||||||
|
pub use reward::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use settlement::*;
|
pub use settlement::*;
|
||||||
pub use student::*;
|
pub use student::*;
|
||||||
|
|||||||
+15
-11
@@ -5,15 +5,13 @@ pub mod services;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
|
||||||
use crate::db::connection::DatabaseType;
|
use crate::db::connection::DatabaseType;
|
||||||
|
use crate::db::connection::{create_postgres_connection, create_sqlite_connection};
|
||||||
use crate::db::migration::run_migration;
|
use crate::db::migration::run_migration;
|
||||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||||
use crate::{commands::*, state::AppState};
|
use crate::{commands::*, state::AppState};
|
||||||
use tauri::{App, Manager};
|
use parking_lot::RwLock;
|
||||||
use tokio::time::{timeout, Duration};
|
use std::sync::Arc;
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
use tauri::{
|
use tauri::{
|
||||||
image::Image,
|
image::Image,
|
||||||
@@ -21,6 +19,8 @@ use tauri::{
|
|||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
WindowEvent,
|
WindowEvent,
|
||||||
};
|
};
|
||||||
|
use tauri::{App, Manager};
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -83,6 +83,7 @@ pub fn run() {
|
|||||||
auto_score_toggle_rule,
|
auto_score_toggle_rule,
|
||||||
auto_score_get_status,
|
auto_score_get_status,
|
||||||
auto_score_sort_rules,
|
auto_score_sort_rules,
|
||||||
|
board_query_sql,
|
||||||
log_query,
|
log_query,
|
||||||
log_clear,
|
log_clear,
|
||||||
log_set_level,
|
log_set_level,
|
||||||
@@ -149,10 +150,7 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
data_dir.join("data.sql")
|
data_dir.join("data.sql")
|
||||||
};
|
};
|
||||||
|
|
||||||
let db_path_str = db_path
|
let db_path_str = db_path.to_str().ok_or("Invalid database path")?.to_string();
|
||||||
.to_str()
|
|
||||||
.ok_or("Invalid database path")?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let db_result = tauri::async_runtime::block_on(async {
|
let db_result = tauri::async_runtime::block_on(async {
|
||||||
let sqlite_conn = create_sqlite_connection(&db_path_str).await?;
|
let sqlite_conn = create_sqlite_connection(&db_path_str).await?;
|
||||||
@@ -237,7 +235,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| format!("Failed to save pg status: {}", err))?;
|
.map_err(|err| format!("Failed to save pg status: {}", err))?;
|
||||||
} else if let Ok(Err(e)) = migration_result {
|
} else if let Ok(Err(e)) = migration_result {
|
||||||
eprintln!("PostgreSQL migration failed on startup, fallback to sqlite: {}", e);
|
eprintln!(
|
||||||
|
"PostgreSQL migration failed on startup, fallback to sqlite: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
settings
|
settings
|
||||||
.set_value(
|
.set_value(
|
||||||
SettingsKey::PgConnectionStatus,
|
SettingsKey::PgConnectionStatus,
|
||||||
@@ -290,7 +291,10 @@ fn setup_database(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Err(e) = db_result {
|
if let Err(e) = db_result {
|
||||||
eprintln!("Failed to connect to database: {}", e);
|
eprintln!("Failed to connect to database: {}", e);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Database bootstrap completed, sqlite settings db: {}", db_path_str);
|
eprintln!(
|
||||||
|
"Database bootstrap completed, sqlite settings db: {}",
|
||||||
|
db_path_str
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -14,6 +14,7 @@ pub struct SettingsSpec {
|
|||||||
pub auto_score_enabled: bool,
|
pub auto_score_enabled: bool,
|
||||||
pub auto_score_rules: JsonValue,
|
pub auto_score_rules: JsonValue,
|
||||||
pub current_theme_id: String,
|
pub current_theme_id: String,
|
||||||
|
pub dashboards_config: JsonValue,
|
||||||
pub pg_connection_string: String,
|
pub pg_connection_string: String,
|
||||||
pub pg_connection_status: JsonValue,
|
pub pg_connection_status: JsonValue,
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ impl Default for SettingsSpec {
|
|||||||
auto_score_enabled: false,
|
auto_score_enabled: false,
|
||||||
auto_score_rules: JsonValue::Array(vec![]),
|
auto_score_rules: JsonValue::Array(vec![]),
|
||||||
current_theme_id: "light-default".to_string(),
|
current_theme_id: "light-default".to_string(),
|
||||||
|
dashboards_config: JsonValue::Array(vec![]),
|
||||||
pg_connection_string: String::new(),
|
pg_connection_string: String::new(),
|
||||||
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
|
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
|
||||||
}
|
}
|
||||||
@@ -47,6 +49,7 @@ pub enum SettingsKey {
|
|||||||
AutoScoreEnabled,
|
AutoScoreEnabled,
|
||||||
AutoScoreRules,
|
AutoScoreRules,
|
||||||
CurrentThemeId,
|
CurrentThemeId,
|
||||||
|
DashboardsConfig,
|
||||||
PgConnectionString,
|
PgConnectionString,
|
||||||
PgConnectionStatus,
|
PgConnectionStatus,
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,7 @@ impl SettingsKey {
|
|||||||
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
||||||
SettingsKey::AutoScoreRules => "auto_score_rules",
|
SettingsKey::AutoScoreRules => "auto_score_rules",
|
||||||
SettingsKey::CurrentThemeId => "current_theme_id",
|
SettingsKey::CurrentThemeId => "current_theme_id",
|
||||||
|
SettingsKey::DashboardsConfig => "dashboards_config",
|
||||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||||
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
||||||
}
|
}
|
||||||
@@ -79,6 +83,7 @@ impl SettingsKey {
|
|||||||
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
||||||
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
||||||
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
||||||
|
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
||||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||||
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -346,6 +351,16 @@ impl SettingsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
defs.insert(
|
||||||
|
SettingsKey::DashboardsConfig,
|
||||||
|
SettingDefinition {
|
||||||
|
kind: SettingValueKind::Json,
|
||||||
|
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||||
|
write_permission: PermissionRequirement::Admin,
|
||||||
|
validate: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
defs.insert(
|
defs.insert(
|
||||||
SettingsKey::PgConnectionString,
|
SettingsKey::PgConnectionString,
|
||||||
SettingDefinition {
|
SettingDefinition {
|
||||||
@@ -494,6 +509,10 @@ impl SettingsService {
|
|||||||
SettingsValue::String(s) => s,
|
SettingsValue::String(s) => s,
|
||||||
_ => "light-default".to_string(),
|
_ => "light-default".to_string(),
|
||||||
},
|
},
|
||||||
|
dashboards_config: match self.get_value(SettingsKey::DashboardsConfig) {
|
||||||
|
SettingsValue::Json(j) => j,
|
||||||
|
_ => JsonValue::Array(vec![]),
|
||||||
|
},
|
||||||
pg_connection_string: match self.get_value(SettingsKey::PgConnectionString) {
|
pg_connection_string: match self.get_value(SettingsKey::PgConnectionString) {
|
||||||
SettingsValue::String(s) => s,
|
SettingsValue::String(s) => s,
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (p === "/" || p.startsWith("/home")) return "home"
|
if (p === "/" || p.startsWith("/home")) return "home"
|
||||||
if (p.startsWith("/students")) return "students"
|
if (p.startsWith("/students")) return "students"
|
||||||
if (p.startsWith("/score")) return "score"
|
if (p.startsWith("/score")) return "score"
|
||||||
|
if (p.startsWith("/boards")) return "boards"
|
||||||
if (p.startsWith("/leaderboard")) return "leaderboard"
|
if (p.startsWith("/leaderboard")) return "leaderboard"
|
||||||
if (p.startsWith("/settlements")) return "settlements"
|
if (p.startsWith("/settlements")) return "settlements"
|
||||||
if (p.startsWith("/reasons")) return "reasons"
|
if (p.startsWith("/reasons")) return "reasons"
|
||||||
@@ -269,6 +270,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (key === "home") navigate("/")
|
if (key === "home") navigate("/")
|
||||||
if (key === "students") navigate("/students")
|
if (key === "students") navigate("/students")
|
||||||
if (key === "score") navigate("/score")
|
if (key === "score") navigate("/score")
|
||||||
|
if (key === "boards") navigate("/boards")
|
||||||
if (key === "leaderboard") navigate("/leaderboard")
|
if (key === "leaderboard") navigate("/leaderboard")
|
||||||
if (key === "settlements") navigate("/settlements")
|
if (key === "settlements") navigate("/settlements")
|
||||||
if (key === "reasons") navigate("/reasons")
|
if (key === "reasons") navigate("/reasons")
|
||||||
|
|||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from "antd"
|
||||||
|
import { DeleteOutlined, PlayCircleOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
interface StudentListConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sql: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lists: StudentListConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
sql: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardManagerProps {
|
||||||
|
canManage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeId = () =>
|
||||||
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
|
const getDefaultSql = () => `SELECT
|
||||||
|
name AS student_name,
|
||||||
|
score,
|
||||||
|
reward_points
|
||||||
|
FROM students
|
||||||
|
ORDER BY score DESC`
|
||||||
|
|
||||||
|
const createDefaultList = (): StudentListConfig => ({
|
||||||
|
id: makeId(),
|
||||||
|
name: "学生积分榜",
|
||||||
|
sql: getDefaultSql(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultBoard = (): BoardConfig => ({
|
||||||
|
id: makeId(),
|
||||||
|
name: "默认看板",
|
||||||
|
lists: [createDefaultList()],
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeBoards = (input: unknown): BoardConfig[] => {
|
||||||
|
if (!Array.isArray(input)) return [createDefaultBoard()]
|
||||||
|
|
||||||
|
const boards: BoardConfig[] = input
|
||||||
|
.map((board: any) => {
|
||||||
|
const lists = Array.isArray(board?.lists)
|
||||||
|
? board.lists
|
||||||
|
.map((list: any) => ({
|
||||||
|
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
|
||||||
|
name:
|
||||||
|
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
||||||
|
sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(),
|
||||||
|
}))
|
||||||
|
.filter((list: StudentListConfig) => list.sql.trim())
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof board?.id === "string" && board.id.trim() ? board.id : makeId(),
|
||||||
|
name: typeof board?.name === "string" && board.name.trim() ? board.name.trim() : "未命名看板",
|
||||||
|
lists: lists.length > 0 ? lists : [createDefaultList()],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((board: BoardConfig) => board.id)
|
||||||
|
|
||||||
|
return boards.length > 0 ? boards : [createDefaultBoard()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSqlTemplate = (sql: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
const at = (offsetDays: number) => new Date(now.getTime() + offsetDays * dayMs)
|
||||||
|
|
||||||
|
const formatIso = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||||
|
|
||||||
|
const todayStart = new Date(now)
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
return sql
|
||||||
|
.split("{{now}}")
|
||||||
|
.join(formatIso(now))
|
||||||
|
.split("{{today_start}}")
|
||||||
|
.join(formatIso(todayStart))
|
||||||
|
.split("{{since_7d}}")
|
||||||
|
.join(formatIso(at(-7)))
|
||||||
|
.split("{{since_30d}}")
|
||||||
|
.join(formatIso(at(-30)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [boards, setBoards] = useState<BoardConfig[]>([])
|
||||||
|
const [activeBoardId, setActiveBoardId] = useState<string>("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [runningIds, setRunningIds] = useState<Record<string, boolean>>({})
|
||||||
|
const [resultMap, setResultMap] = useState<Record<string, any[]>>({})
|
||||||
|
const [errorMap, setErrorMap] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const presets: BoardPreset[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "week-low-deduct",
|
||||||
|
name: t("board.presets.weekLowDeduct.name"),
|
||||||
|
description: t("board.presets.weekLowDeduct.description"),
|
||||||
|
sql: `WITH week_stat AS (
|
||||||
|
SELECT
|
||||||
|
student_name,
|
||||||
|
SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END) AS deducted,
|
||||||
|
SUM(delta) AS week_change
|
||||||
|
FROM score_events
|
||||||
|
WHERE event_time >= '{{since_7d}}'
|
||||||
|
GROUP BY student_name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.name AS student_name,
|
||||||
|
s.score,
|
||||||
|
COALESCE(w.week_change, 0) AS week_change,
|
||||||
|
COALESCE(w.deducted, 0) AS week_deducted
|
||||||
|
FROM students s
|
||||||
|
LEFT JOIN week_stat w ON w.student_name = s.name
|
||||||
|
WHERE COALESCE(w.deducted, 0) < 3
|
||||||
|
ORDER BY s.score DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "today-active",
|
||||||
|
name: t("board.presets.todayActive.name"),
|
||||||
|
description: t("board.presets.todayActive.description"),
|
||||||
|
sql: `SELECT
|
||||||
|
student_name,
|
||||||
|
COUNT(*) AS answered_count,
|
||||||
|
SUM(delta) AS score_change
|
||||||
|
FROM score_events
|
||||||
|
WHERE event_time >= '{{today_start}}'
|
||||||
|
GROUP BY student_name
|
||||||
|
ORDER BY answered_count DESC, score_change DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reward-ranking",
|
||||||
|
name: t("board.presets.rewardRanking.name"),
|
||||||
|
description: t("board.presets.rewardRanking.description"),
|
||||||
|
sql: `SELECT
|
||||||
|
name AS student_name,
|
||||||
|
score,
|
||||||
|
reward_points
|
||||||
|
FROM students
|
||||||
|
ORDER BY reward_points DESC, score DESC`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeBoard = useMemo(
|
||||||
|
() => boards.find((board) => board.id === activeBoardId) || null,
|
||||||
|
[boards, activeBoardId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveBoards = useCallback(
|
||||||
|
async (nextBoards: BoardConfig[]) => {
|
||||||
|
if (!(window as any).api || !canManage) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await (window as any).api.setSetting("dashboards_config", nextBoards)
|
||||||
|
if (!res?.success) {
|
||||||
|
messageApi.error(res?.message || t("board.saveFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t("board.saveFailed"))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canManage, messageApi, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchBoards = useCallback(async () => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await (window as any).api.getSetting("dashboards_config")
|
||||||
|
if (res?.success) {
|
||||||
|
const normalized = normalizeBoards(res?.data)
|
||||||
|
setBoards(normalized)
|
||||||
|
setActiveBoardId((prev) => prev || normalized[0]?.id || "")
|
||||||
|
} else {
|
||||||
|
const fallback = [createDefaultBoard()]
|
||||||
|
setBoards(fallback)
|
||||||
|
setActiveBoardId(fallback[0].id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const fallback = [createDefaultBoard()]
|
||||||
|
setBoards(fallback)
|
||||||
|
setActiveBoardId(fallback[0].id)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const runListQuery = useCallback(
|
||||||
|
async (list: StudentListConfig) => {
|
||||||
|
if (!(window as any).api) return
|
||||||
|
const sql = resolveSqlTemplate(list.sql)
|
||||||
|
|
||||||
|
setRunningIds((prev) => ({ ...prev, [list.id]: true }))
|
||||||
|
setErrorMap((prev) => ({ ...prev, [list.id]: "" }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await (window as any).api.boardQuerySql({ sql, limit: 500 })
|
||||||
|
if (res?.success) {
|
||||||
|
setResultMap((prev) => ({ ...prev, [list.id]: Array.isArray(res.data) ? res.data : [] }))
|
||||||
|
} else {
|
||||||
|
setErrorMap((prev) => ({ ...prev, [list.id]: res?.message || t("board.runFailed") }))
|
||||||
|
setResultMap((prev) => ({ ...prev, [list.id]: [] }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrorMap((prev) => ({ ...prev, [list.id]: t("board.runFailed") }))
|
||||||
|
setResultMap((prev) => ({ ...prev, [list.id]: [] }))
|
||||||
|
} finally {
|
||||||
|
setRunningIds((prev) => ({ ...prev, [list.id]: false }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const runAllInBoard = useCallback(
|
||||||
|
async (board: BoardConfig | null) => {
|
||||||
|
if (!board) return
|
||||||
|
for (const list of board.lists) {
|
||||||
|
await runListQuery(list)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runListQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBoards()
|
||||||
|
}, [fetchBoards])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBoardId && boards.length > 0) {
|
||||||
|
setActiveBoardId(boards[0].id)
|
||||||
|
}
|
||||||
|
}, [activeBoardId, boards])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBoard) return
|
||||||
|
runAllInBoard(activeBoard).catch(() => void 0)
|
||||||
|
}, [activeBoardId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDataUpdated = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ category?: string }>).detail
|
||||||
|
if (!detail?.category || detail.category === "all") {
|
||||||
|
runAllInBoard(activeBoard).catch(() => void 0)
|
||||||
|
}
|
||||||
|
if (detail.category === "students" || detail.category === "events" || detail.category === "reasons") {
|
||||||
|
runAllInBoard(activeBoard).catch(() => void 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("ss:data-updated", onDataUpdated)
|
||||||
|
return () => window.removeEventListener("ss:data-updated", onDataUpdated)
|
||||||
|
}, [activeBoard, runAllInBoard])
|
||||||
|
|
||||||
|
const mutateBoards = (updater: (prev: BoardConfig[]) => BoardConfig[]) => {
|
||||||
|
setBoards((prev) => {
|
||||||
|
const next = updater(prev)
|
||||||
|
if (canManage) {
|
||||||
|
saveBoards(next).catch(() => void 0)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBoardName = (boardId: string, name: string) => {
|
||||||
|
mutateBoards((prev) =>
|
||||||
|
prev.map((board) => (board.id === boardId ? { ...board, name: name || t("board.untitledBoard") } : board))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBoard = () => {
|
||||||
|
if (!canManage) return
|
||||||
|
const newBoard: BoardConfig = {
|
||||||
|
id: makeId(),
|
||||||
|
name: t("board.newBoard"),
|
||||||
|
lists: [createDefaultList()],
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateBoards((prev) => [...prev, newBoard])
|
||||||
|
setActiveBoardId(newBoard.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeBoard = (boardId: string) => {
|
||||||
|
if (!canManage) return
|
||||||
|
mutateBoards((prev) => {
|
||||||
|
if (prev.length <= 1) {
|
||||||
|
messageApi.warning(t("board.keepAtLeastOneBoard"))
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next = prev.filter((board) => board.id !== boardId)
|
||||||
|
if (activeBoardId === boardId && next.length > 0) {
|
||||||
|
setActiveBoardId(next[0].id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addList = (boardId: string) => {
|
||||||
|
if (!canManage) return
|
||||||
|
mutateBoards((prev) =>
|
||||||
|
prev.map((board) =>
|
||||||
|
board.id === boardId
|
||||||
|
? {
|
||||||
|
...board,
|
||||||
|
lists: [
|
||||||
|
...board.lists,
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
name: t("board.newList"),
|
||||||
|
sql: getDefaultSql(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: board
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeList = (boardId: string, listId: string) => {
|
||||||
|
if (!canManage) return
|
||||||
|
mutateBoards((prev) =>
|
||||||
|
prev.map((board) => {
|
||||||
|
if (board.id !== boardId) return board
|
||||||
|
if (board.lists.length <= 1) {
|
||||||
|
messageApi.warning(t("board.keepAtLeastOneList"))
|
||||||
|
return board
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
lists: board.lists.filter((list) => list.id !== listId),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateList = (boardId: string, listId: string, patch: Partial<StudentListConfig>) => {
|
||||||
|
mutateBoards((prev) =>
|
||||||
|
prev.map((board) =>
|
||||||
|
board.id === boardId
|
||||||
|
? {
|
||||||
|
...board,
|
||||||
|
lists: board.lists.map((list) =>
|
||||||
|
list.id === listId
|
||||||
|
? {
|
||||||
|
...list,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
: list
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: board
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = (boardId: string, listId: string, presetId: string) => {
|
||||||
|
const preset = presets.find((item) => item.id === presetId)
|
||||||
|
if (!preset) return
|
||||||
|
|
||||||
|
updateList(boardId, listId, {
|
||||||
|
name: preset.name,
|
||||||
|
sql: preset.sql,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
{contextHolder}
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||||
|
<Space align="center">
|
||||||
|
<Typography.Title level={2} style={{ margin: 0, color: "var(--ss-text-main)" }}>
|
||||||
|
{t("board.title")}
|
||||||
|
</Typography.Title>
|
||||||
|
<Tag color={canManage ? "success" : "default"}>
|
||||||
|
{canManage ? t("board.editable") : t("board.readonly")}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={fetchBoards}>
|
||||||
|
{t("common.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={addBoard} disabled={!canManage}>
|
||||||
|
{t("board.addBoard")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={t("board.templateHint")}
|
||||||
|
description={t("board.templateDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
type="card"
|
||||||
|
activeKey={activeBoardId}
|
||||||
|
onChange={setActiveBoardId}
|
||||||
|
items={boards.map((board) => ({
|
||||||
|
key: board.id,
|
||||||
|
label: board.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeBoard ? (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
|
<Card
|
||||||
|
title={t("board.boardConfig")}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => runAllInBoard(activeBoard)} icon={<PlayCircleOutlined />}>
|
||||||
|
{t("board.runAll")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => addList(activeBoard.id)} icon={<PlusOutlined />} disabled={!canManage}>
|
||||||
|
{t("board.addList")}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t("board.removeBoardConfirm")}
|
||||||
|
onConfirm={() => removeBoard(activeBoard.id)}
|
||||||
|
disabled={!canManage}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />} disabled={!canManage}>
|
||||||
|
{t("board.removeBoard")}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: "var(--ss-card-bg)" }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={activeBoard.name}
|
||||||
|
onChange={(e) => updateBoardName(activeBoard.id, e.target.value.trim())}
|
||||||
|
placeholder={t("board.boardNamePlaceholder")}
|
||||||
|
disabled={!canManage}
|
||||||
|
/>
|
||||||
|
{saving && (
|
||||||
|
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
|
||||||
|
{t("board.saving")}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{activeBoard.lists.map((list) => {
|
||||||
|
const rows = resultMap[list.id] || []
|
||||||
|
const columns =
|
||||||
|
rows.length > 0
|
||||||
|
? Object.keys(rows[0]).map((key) => ({
|
||||||
|
title: key,
|
||||||
|
dataIndex: key,
|
||||||
|
key,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: any) =>
|
||||||
|
value === null || value === undefined || value === "" ? "-" : String(value),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={list.id}
|
||||||
|
title={
|
||||||
|
<Input
|
||||||
|
value={list.name}
|
||||||
|
onChange={(e) => updateList(activeBoard.id, list.id, { name: e.target.value })}
|
||||||
|
placeholder={t("board.listNamePlaceholder")}
|
||||||
|
disabled={!canManage}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 260 }}
|
||||||
|
placeholder={t("board.applyPreset")}
|
||||||
|
options={presets.map((preset) => ({
|
||||||
|
value: preset.id,
|
||||||
|
label: `${preset.name} · ${preset.description}`,
|
||||||
|
}))}
|
||||||
|
onChange={(presetId) => applyPreset(activeBoard.id, list.id, presetId)}
|
||||||
|
disabled={!canManage}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={Boolean(runningIds[list.id])}
|
||||||
|
onClick={() => runListQuery(list)}
|
||||||
|
>
|
||||||
|
{t("board.run")}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t("board.removeListConfirm")}
|
||||||
|
onConfirm={() => removeList(activeBoard.id, list.id)}
|
||||||
|
disabled={!canManage}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={!canManage || activeBoard.lists.length <= 1}
|
||||||
|
>
|
||||||
|
{t("board.removeList")}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: "var(--ss-card-bg)" }}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
value={list.sql}
|
||||||
|
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||||
|
onChange={(e) => updateList(activeBoard.id, list.id, { sql: e.target.value })}
|
||||||
|
disabled={!canManage}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={t("board.sqlPlaceholder")}
|
||||||
|
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMap[list.id] && (
|
||||||
|
<Alert
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
type="error"
|
||||||
|
message={errorMap[list.id]}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Table
|
||||||
|
rowKey={(_, index) => `${list.id}-${index}`}
|
||||||
|
dataSource={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={Boolean(runningIds[list.id])}
|
||||||
|
locale={{
|
||||||
|
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("common.noData")} />,
|
||||||
|
}}
|
||||||
|
scroll={{ x: true }}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Empty description={t("common.noData")} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const loadLeaderboard = () => import("./Leaderboard")
|
|||||||
const loadSettlementHistory = () => import("./SettlementHistory")
|
const loadSettlementHistory = () => import("./SettlementHistory")
|
||||||
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
||||||
const loadRewardSettings = () => import("./RewardSettings")
|
const loadRewardSettings = () => import("./RewardSettings")
|
||||||
|
const loadBoardManager = () => import("./BoardManager")
|
||||||
|
|
||||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||||
@@ -30,6 +31,7 @@ const AutoScoreManager = lazy(() =>
|
|||||||
const RewardSettings = lazy(() =>
|
const RewardSettings = lazy(() =>
|
||||||
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
|
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
|
||||||
)
|
)
|
||||||
|
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
||||||
|
|
||||||
const warmupRouteChunks = () =>
|
const warmupRouteChunks = () =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
@@ -42,6 +44,7 @@ const warmupRouteChunks = () =>
|
|||||||
loadSettlementHistory(),
|
loadSettlementHistory(),
|
||||||
loadAutoScoreManager(),
|
loadAutoScoreManager(),
|
||||||
loadRewardSettings(),
|
loadRewardSettings(),
|
||||||
|
loadBoardManager(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
@@ -236,6 +239,7 @@ export function ContentArea({
|
|||||||
path="/score"
|
path="/score"
|
||||||
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||||
<Route path="/settlements" element={<SettlementHistory />} />
|
<Route path="/settlements" element={<SettlementHistory />} />
|
||||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CloudOutlined,
|
CloudOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
AppstoreAddOutlined,
|
AppstoreAddOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
} from "@ant-design/icons"
|
} from "@ant-design/icons"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -173,6 +174,11 @@ export function Sidebar({
|
|||||||
label: t("sidebar.rewardSettings"),
|
label: t("sidebar.rewardSettings"),
|
||||||
disabled: permission !== "admin",
|
disabled: permission !== "admin",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "boards",
|
||||||
|
icon: <ApartmentOutlined />,
|
||||||
|
label: t("sidebar.boards"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "leaderboard",
|
key: "leaderboard",
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
|
|||||||
@@ -306,6 +306,7 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"students": "Students",
|
"students": "Students",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
"boards": "Boards",
|
||||||
"leaderboard": "Leaderboard",
|
"leaderboard": "Leaderboard",
|
||||||
"settlements": "Settlements",
|
"settlements": "Settlements",
|
||||||
"reasons": "Reasons",
|
"reasons": "Reasons",
|
||||||
@@ -558,6 +559,48 @@
|
|||||||
"deleteFailed": "Delete reward failed",
|
"deleteFailed": "Delete reward failed",
|
||||||
"deleteConfirm": "Delete reward \"{{name}}\"?"
|
"deleteConfirm": "Delete reward \"{{name}}\"?"
|
||||||
},
|
},
|
||||||
|
"board": {
|
||||||
|
"title": "Boards",
|
||||||
|
"editable": "Editable",
|
||||||
|
"readonly": "Read-only",
|
||||||
|
"addBoard": "Add Board",
|
||||||
|
"removeBoard": "Delete Board",
|
||||||
|
"removeBoardConfirm": "Delete current board?",
|
||||||
|
"boardConfig": "Board Config",
|
||||||
|
"boardNamePlaceholder": "Enter board name",
|
||||||
|
"newBoard": "New Board",
|
||||||
|
"untitledBoard": "Untitled Board",
|
||||||
|
"addList": "Add Student List",
|
||||||
|
"removeList": "Delete List",
|
||||||
|
"removeListConfirm": "Delete current student list?",
|
||||||
|
"listNamePlaceholder": "Enter list name",
|
||||||
|
"newList": "New List",
|
||||||
|
"applyPreset": "Apply Preset",
|
||||||
|
"run": "Run SQL",
|
||||||
|
"runAll": "Run All",
|
||||||
|
"sqlPlaceholder": "Enter SQL (single SELECT / WITH query only)",
|
||||||
|
"templateHint": "Template variables: {{today_start}}, {{since_7d}}, {{since_30d}}, {{now}}",
|
||||||
|
"templateDescription": "Use template variables directly in SQL. They will be replaced with ISO timestamps at runtime for both SQLite and PostgreSQL.",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saveFailed": "Failed to save boards",
|
||||||
|
"runFailed": "Failed to run query",
|
||||||
|
"keepAtLeastOneBoard": "Keep at least one board",
|
||||||
|
"keepAtLeastOneList": "Keep at least one student list",
|
||||||
|
"presets": {
|
||||||
|
"weekLowDeduct": {
|
||||||
|
"name": "Low Deduction (7d)",
|
||||||
|
"description": "Rank students with weekly deduction < 3"
|
||||||
|
},
|
||||||
|
"todayActive": {
|
||||||
|
"name": "Today's Activity",
|
||||||
|
"description": "Sort by today's answer count and score change"
|
||||||
|
},
|
||||||
|
"rewardRanking": {
|
||||||
|
"name": "Reward Ranking",
|
||||||
|
"description": "Sort by reward points and total score"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"leaderboard": {
|
"leaderboard": {
|
||||||
"title": "Points Leaderboard",
|
"title": "Points Leaderboard",
|
||||||
"rank": "Rank",
|
"rank": "Rank",
|
||||||
|
|||||||
@@ -306,6 +306,7 @@
|
|||||||
"home": "首页",
|
"home": "首页",
|
||||||
"students": "学生管理",
|
"students": "学生管理",
|
||||||
"score": "积分操作",
|
"score": "积分操作",
|
||||||
|
"boards": "看板",
|
||||||
"leaderboard": "排行榜",
|
"leaderboard": "排行榜",
|
||||||
"settlements": "结算历史",
|
"settlements": "结算历史",
|
||||||
"reasons": "理由管理",
|
"reasons": "理由管理",
|
||||||
@@ -558,6 +559,48 @@
|
|||||||
"deleteFailed": "奖励删除失败",
|
"deleteFailed": "奖励删除失败",
|
||||||
"deleteConfirm": "确认删除奖励「{{name}}」?"
|
"deleteConfirm": "确认删除奖励「{{name}}」?"
|
||||||
},
|
},
|
||||||
|
"board": {
|
||||||
|
"title": "看板",
|
||||||
|
"editable": "可编辑",
|
||||||
|
"readonly": "只读",
|
||||||
|
"addBoard": "新增看板",
|
||||||
|
"removeBoard": "删除看板",
|
||||||
|
"removeBoardConfirm": "确认删除当前看板?",
|
||||||
|
"boardConfig": "看板配置",
|
||||||
|
"boardNamePlaceholder": "输入看板名称",
|
||||||
|
"newBoard": "新看板",
|
||||||
|
"untitledBoard": "未命名看板",
|
||||||
|
"addList": "新增学生列表",
|
||||||
|
"removeList": "删除列表",
|
||||||
|
"removeListConfirm": "确认删除当前学生列表?",
|
||||||
|
"listNamePlaceholder": "输入列表名称",
|
||||||
|
"newList": "新列表",
|
||||||
|
"applyPreset": "套用预设",
|
||||||
|
"run": "运行 SQL",
|
||||||
|
"runAll": "运行全部",
|
||||||
|
"sqlPlaceholder": "输入 SQL(仅支持单条 SELECT / WITH 查询)",
|
||||||
|
"templateHint": "支持时间模板变量:{{today_start}}、{{since_7d}}、{{since_30d}}、{{now}}",
|
||||||
|
"templateDescription": "可直接在 SQL 中使用模板变量,运行时会自动替换为 ISO 时间,兼容 SQLite 与 PostgreSQL。",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saveFailed": "保存看板失败",
|
||||||
|
"runFailed": "查询执行失败",
|
||||||
|
"keepAtLeastOneBoard": "至少保留一个看板",
|
||||||
|
"keepAtLeastOneList": "至少保留一个学生列表",
|
||||||
|
"presets": {
|
||||||
|
"weekLowDeduct": {
|
||||||
|
"name": "上周低扣分排行",
|
||||||
|
"description": "上一周扣分 < 3 的学生积分榜"
|
||||||
|
},
|
||||||
|
"todayActive": {
|
||||||
|
"name": "今日活跃榜",
|
||||||
|
"description": "按今日回答次数与积分变化排序"
|
||||||
|
},
|
||||||
|
"rewardRanking": {
|
||||||
|
"name": "奖励积分榜",
|
||||||
|
"description": "按奖励积分和总积分排序"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"leaderboard": {
|
"leaderboard": {
|
||||||
"title": "积分排行榜",
|
"title": "积分排行榜",
|
||||||
"rank": "排名",
|
"rank": "排名",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type settingsKey =
|
|||||||
| "auto_score_enabled"
|
| "auto_score_enabled"
|
||||||
| "auto_score_rules"
|
| "auto_score_rules"
|
||||||
| "current_theme_id"
|
| "current_theme_id"
|
||||||
|
| "dashboards_config"
|
||||||
| "pg_connection_string"
|
| "pg_connection_string"
|
||||||
| "pg_connection_status"
|
| "pg_connection_status"
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export interface settingsSpec {
|
|||||||
auto_score_enabled: boolean
|
auto_score_enabled: boolean
|
||||||
auto_score_rules: any[]
|
auto_score_rules: any[]
|
||||||
current_theme_id: string
|
current_theme_id: string
|
||||||
|
dashboards_config: any[]
|
||||||
pg_connection_string: string
|
pg_connection_string: string
|
||||||
pg_connection_status: {
|
pg_connection_status: {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
@@ -141,6 +143,11 @@ const api = {
|
|||||||
range: "today" | "week" | "month"
|
range: "today" | "week" | "month"
|
||||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||||
invoke("leaderboard_query", { params }),
|
invoke("leaderboard_query", { params }),
|
||||||
|
boardQuerySql: (params: {
|
||||||
|
sql: string
|
||||||
|
limit?: number
|
||||||
|
}): Promise<{ success: boolean; data: any[]; message?: string }> =>
|
||||||
|
invoke("board_query_sql", { params }),
|
||||||
|
|
||||||
// Settlement
|
// Settlement
|
||||||
querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),
|
querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),
|
||||||
|
|||||||
Reference in New Issue
Block a user