mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +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 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::*;
|
||||
|
||||
+15
-11
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn std::error::Error>> {
|
||||
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(())
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 loadAutoScoreManager = () => import("./AutoScoreManager")
|
||||
const loadRewardSettings = () => import("./RewardSettings")
|
||||
const loadBoardManager = () => import("./BoardManager")
|
||||
|
||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||
@@ -30,6 +31,7 @@ const AutoScoreManager = lazy(() =>
|
||||
const RewardSettings = lazy(() =>
|
||||
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
|
||||
)
|
||||
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
||||
|
||||
const warmupRouteChunks = () =>
|
||||
Promise.allSettled([
|
||||
@@ -42,6 +44,7 @@ const warmupRouteChunks = () =>
|
||||
loadSettlementHistory(),
|
||||
loadAutoScoreManager(),
|
||||
loadRewardSettings(),
|
||||
loadBoardManager(),
|
||||
])
|
||||
|
||||
const { Content } = Layout
|
||||
@@ -236,6 +239,7 @@ export function ContentArea({
|
||||
path="/score"
|
||||
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CloudOutlined,
|
||||
UploadOutlined,
|
||||
AppstoreAddOutlined,
|
||||
ApartmentOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -173,6 +174,11 @@ export function Sidebar({
|
||||
label: t("sidebar.rewardSettings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "boards",
|
||||
icon: <ApartmentOutlined />,
|
||||
label: t("sidebar.boards"),
|
||||
},
|
||||
{
|
||||
key: "leaderboard",
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"home": "Home",
|
||||
"students": "Students",
|
||||
"score": "Score",
|
||||
"boards": "Boards",
|
||||
"leaderboard": "Leaderboard",
|
||||
"settlements": "Settlements",
|
||||
"reasons": "Reasons",
|
||||
@@ -558,6 +559,48 @@
|
||||
"deleteFailed": "Delete reward failed",
|
||||
"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": {
|
||||
"title": "Points Leaderboard",
|
||||
"rank": "Rank",
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"home": "首页",
|
||||
"students": "学生管理",
|
||||
"score": "积分操作",
|
||||
"boards": "看板",
|
||||
"leaderboard": "排行榜",
|
||||
"settlements": "结算历史",
|
||||
"reasons": "理由管理",
|
||||
@@ -558,6 +559,48 @@
|
||||
"deleteFailed": "奖励删除失败",
|
||||
"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": {
|
||||
"title": "积分排行榜",
|
||||
"rank": "排名",
|
||||
|
||||
@@ -27,6 +27,7 @@ export type settingsKey =
|
||||
| "auto_score_enabled"
|
||||
| "auto_score_rules"
|
||||
| "current_theme_id"
|
||||
| "dashboards_config"
|
||||
| "pg_connection_string"
|
||||
| "pg_connection_status"
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface settingsSpec {
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: any[]
|
||||
current_theme_id: string
|
||||
dashboards_config: any[]
|
||||
pg_connection_string: string
|
||||
pg_connection_status: {
|
||||
connected: boolean
|
||||
@@ -141,6 +143,11 @@ const api = {
|
||||
range: "today" | "week" | "month"
|
||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||
invoke("leaderboard_query", { params }),
|
||||
boardQuerySql: (params: {
|
||||
sql: string
|
||||
limit?: number
|
||||
}): Promise<{ success: boolean; data: any[]; message?: string }> =>
|
||||
invoke("board_query_sql", { params }),
|
||||
|
||||
// Settlement
|
||||
querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),
|
||||
|
||||
Reference in New Issue
Block a user