新增看板模块:支持多看板多SQL学生列表与预设模板

This commit is contained in:
JSR
2026-03-20 18:41:45 +08:00
parent ca8068dfff
commit 61f3b24a1c
11 changed files with 1003 additions and 14 deletions
+270
View File
@@ -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(&params.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)),
}
}
+4 -2
View File
@@ -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
View File
@@ -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(())
+20 -1
View File
@@ -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(),
+2
View File
@@ -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")
+589
View File
@@ -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>
)
}
+4
View File
@@ -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"} />} />
+6
View File
@@ -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 />,
+43
View File
@@ -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",
+43
View File
@@ -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": "排名",
+7
View File
@@ -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"),