From 61f3b24a1c3805607b60db44804f30eb01ff9548 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 18:41:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=9C=8B=E6=9D=BF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=9A=E6=94=AF=E6=8C=81=E5=A4=9A=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E5=A4=9ASQL=E5=AD=A6=E7=94=9F=E5=88=97=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/board.rs | 270 +++++++++++++ src-tauri/src/commands/mod.rs | 6 +- src-tauri/src/lib.rs | 26 +- src-tauri/src/services/settings.rs | 21 +- src/App.tsx | 2 + src/components/BoardManager.tsx | 589 +++++++++++++++++++++++++++++ src/components/ContentArea.tsx | 4 + src/components/Sidebar.tsx | 6 + src/i18n/locales/en-US.json | 43 +++ src/i18n/locales/zh-CN.json | 43 +++ src/preload/types.ts | 7 + 11 files changed, 1003 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/commands/board.rs create mode 100644 src/components/BoardManager.tsx diff --git a/src-tauri/src/commands/board.rs b/src-tauri/src/commands/board.rs new file mode 100644 index 0000000..d6ccab5 --- /dev/null +++ b/src-tauri/src/commands/board.rs @@ -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, +} + +#[derive(Debug, Clone, Copy)] +enum QueryBackend { + Sqlite, + Postgres, +} + +fn check_view_permission(state: &Arc>, sender_id: Option) -> 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 { + match limit { + Some(v) if v > 0 => v.min(500), + _ => 200, + } +} + +fn sqlite_db_path(app_handle: &AppHandle) -> Result { + 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::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(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::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(index) { + return v.map(JsonValue::from).unwrap_or(JsonValue::Null); + } + if let Ok(v) = row.try_get::, _>(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, 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, 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, + state: State<'_, Arc>>, +) -> Result>, 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)), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f7fdd5b..5b3419c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod auth; pub mod auto_score; +pub mod board; pub mod data; pub mod database; pub mod event; @@ -8,8 +9,8 @@ pub mod filesystem; pub mod http_server; pub mod log; pub mod reason; -pub mod reward; pub mod response; +pub mod reward; pub mod settings; pub mod settlement; pub mod student; @@ -20,6 +21,7 @@ pub mod window; pub use app::*; pub use auth::*; pub use auto_score::*; +pub use board::*; pub use data::*; pub use database::*; pub use event::*; @@ -27,8 +29,8 @@ pub use filesystem::*; pub use http_server::*; pub use log::*; pub use reason::*; -pub use reward::*; pub use response::*; +pub use reward::*; pub use settings::*; pub use settlement::*; pub use student::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e084bb..408dab1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,15 +5,13 @@ pub mod services; pub mod state; 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::{create_postgres_connection, create_sqlite_connection}; use crate::db::migration::run_migration; use crate::services::settings::{SettingsKey, SettingsValue}; use crate::{commands::*, state::AppState}; -use tauri::{App, Manager}; -use tokio::time::{timeout, Duration}; +use parking_lot::RwLock; +use std::sync::Arc; #[cfg(desktop)] use tauri::{ image::Image, @@ -21,6 +19,8 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, WindowEvent, }; +use tauri::{App, Manager}; +use tokio::time::{timeout, Duration}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -83,6 +83,7 @@ pub fn run() { auto_score_toggle_rule, auto_score_get_status, auto_score_sort_rules, + board_query_sql, log_query, log_clear, log_set_level, @@ -149,10 +150,7 @@ fn setup_database(app: &mut App) -> Result<(), Box> { data_dir.join("data.sql") }; - let db_path_str = db_path - .to_str() - .ok_or("Invalid database path")? - .to_string(); + let db_path_str = db_path.to_str().ok_or("Invalid database path")?.to_string(); let db_result = tauri::async_runtime::block_on(async { let sqlite_conn = create_sqlite_connection(&db_path_str).await?; @@ -237,7 +235,10 @@ fn setup_database(app: &mut App) -> Result<(), Box> { .await .map_err(|err| format!("Failed to save pg status: {}", err))?; } 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 .set_value( SettingsKey::PgConnectionStatus, @@ -290,7 +291,10 @@ fn setup_database(app: &mut App) -> Result<(), Box> { if let Err(e) = db_result { eprintln!("Failed to connect to database: {}", e); } else { - eprintln!("Database bootstrap completed, sqlite settings db: {}", db_path_str); + eprintln!( + "Database bootstrap completed, sqlite settings db: {}", + db_path_str + ); } Ok(()) diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index eac086b..670cecf 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -1,6 +1,6 @@ +use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -14,6 +14,7 @@ pub struct SettingsSpec { pub auto_score_enabled: bool, pub auto_score_rules: JsonValue, pub current_theme_id: String, + pub dashboards_config: JsonValue, pub pg_connection_string: String, pub pg_connection_status: JsonValue, } @@ -30,6 +31,7 @@ impl Default for SettingsSpec { auto_score_enabled: false, auto_score_rules: JsonValue::Array(vec![]), current_theme_id: "light-default".to_string(), + dashboards_config: JsonValue::Array(vec![]), pg_connection_string: String::new(), pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}), } @@ -47,6 +49,7 @@ pub enum SettingsKey { AutoScoreEnabled, AutoScoreRules, CurrentThemeId, + DashboardsConfig, PgConnectionString, PgConnectionStatus, } @@ -63,6 +66,7 @@ impl SettingsKey { SettingsKey::AutoScoreEnabled => "auto_score_enabled", SettingsKey::AutoScoreRules => "auto_score_rules", SettingsKey::CurrentThemeId => "current_theme_id", + SettingsKey::DashboardsConfig => "dashboards_config", SettingsKey::PgConnectionString => "pg_connection_string", SettingsKey::PgConnectionStatus => "pg_connection_status", } @@ -79,6 +83,7 @@ impl SettingsKey { "auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled), "auto_score_rules" => Some(SettingsKey::AutoScoreRules), "current_theme_id" => Some(SettingsKey::CurrentThemeId), + "dashboards_config" => Some(SettingsKey::DashboardsConfig), "pg_connection_string" => Some(SettingsKey::PgConnectionString), "pg_connection_status" => Some(SettingsKey::PgConnectionStatus), _ => 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( SettingsKey::PgConnectionString, SettingDefinition { @@ -494,6 +509,10 @@ impl SettingsService { SettingsValue::String(s) => s, _ => "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) { SettingsValue::String(s) => s, _ => String::new(), diff --git a/src/App.tsx b/src/App.tsx index 6a604f4..c5f4742 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ function MainContent(): React.JSX.Element { if (p === "/" || p.startsWith("/home")) return "home" if (p.startsWith("/students")) return "students" if (p.startsWith("/score")) return "score" + if (p.startsWith("/boards")) return "boards" if (p.startsWith("/leaderboard")) return "leaderboard" if (p.startsWith("/settlements")) return "settlements" if (p.startsWith("/reasons")) return "reasons" @@ -269,6 +270,7 @@ function MainContent(): React.JSX.Element { if (key === "home") navigate("/") if (key === "students") navigate("/students") if (key === "score") navigate("/score") + if (key === "boards") navigate("/boards") if (key === "leaderboard") navigate("/leaderboard") if (key === "settlements") navigate("/settlements") if (key === "reasons") navigate("/reasons") diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx new file mode 100644 index 0000000..0a0e38f --- /dev/null +++ b/src/components/BoardManager.tsx @@ -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 = ({ canManage }) => { + const { t } = useTranslation() + const [messageApi, contextHolder] = message.useMessage() + const [boards, setBoards] = useState([]) + const [activeBoardId, setActiveBoardId] = useState("") + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [runningIds, setRunningIds] = useState>({}) + const [resultMap, setResultMap] = useState>({}) + const [errorMap, setErrorMap] = useState>({}) + + 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) => { + 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 ( +
+ {contextHolder} + + + + + {t("board.title")} + + + {canManage ? t("board.editable") : t("board.readonly")} + + + + + + + + + + + ({ + key: board.id, + label: board.name, + }))} + /> + + {activeBoard ? ( + + + + + removeBoard(activeBoard.id)} + disabled={!canManage} + > + + + + } + style={{ backgroundColor: "var(--ss-card-bg)" }} + > + updateBoardName(activeBoard.id, e.target.value.trim())} + placeholder={t("board.boardNamePlaceholder")} + disabled={!canManage} + /> + {saving && ( + + {t("board.saving")} + + )} + + + {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 ( + updateList(activeBoard.id, list.id, { name: e.target.value })} + placeholder={t("board.listNamePlaceholder")} + disabled={!canManage} + /> + } + extra={ + +