mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
feat: 新增学生小组设置与主页小组排序
This commit is contained in:
@@ -95,6 +95,7 @@ pub enum ConflictStrategy {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct StudentNormalized {
|
struct StudentNormalized {
|
||||||
name: String,
|
name: String,
|
||||||
|
group_name: Option<String>,
|
||||||
score: i32,
|
score: i32,
|
||||||
reward_points: i32,
|
reward_points: i32,
|
||||||
tags: String,
|
tags: String,
|
||||||
@@ -198,6 +199,7 @@ async fn load_students(
|
|||||||
row.name.clone(),
|
row.name.clone(),
|
||||||
StudentNormalized {
|
StudentNormalized {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
group_name: row.group_name,
|
||||||
score: row.score,
|
score: row.score,
|
||||||
reward_points: row.reward_points,
|
reward_points: row.reward_points,
|
||||||
tags: normalize_tags(&row.tags),
|
tags: normalize_tags(&row.tags),
|
||||||
@@ -403,6 +405,7 @@ async fn upsert_student(
|
|||||||
Some(row) => {
|
Some(row) => {
|
||||||
let normalized_current = StudentNormalized {
|
let normalized_current = StudentNormalized {
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
|
group_name: row.group_name.clone(),
|
||||||
score: row.score,
|
score: row.score,
|
||||||
reward_points: row.reward_points,
|
reward_points: row.reward_points,
|
||||||
tags: normalize_tags(&row.tags),
|
tags: normalize_tags(&row.tags),
|
||||||
@@ -415,6 +418,7 @@ async fn upsert_student(
|
|||||||
}
|
}
|
||||||
let mut active: students::ActiveModel = row.into();
|
let mut active: students::ActiveModel = row.into();
|
||||||
active.score = Set(data.score);
|
active.score = Set(data.score);
|
||||||
|
active.group_name = Set(data.group_name.clone());
|
||||||
active.reward_points = Set(data.reward_points);
|
active.reward_points = Set(data.reward_points);
|
||||||
active.tags = Set(data.tags.clone());
|
active.tags = Set(data.tags.clone());
|
||||||
active.extra_json = Set(data.extra_json.clone());
|
active.extra_json = Set(data.extra_json.clone());
|
||||||
@@ -427,6 +431,7 @@ async fn upsert_student(
|
|||||||
students::ActiveModel {
|
students::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(data.name.clone()),
|
name: Set(data.name.clone()),
|
||||||
|
group_name: Set(data.group_name.clone()),
|
||||||
score: Set(data.score),
|
score: Set(data.score),
|
||||||
reward_points: Set(data.reward_points),
|
reward_points: Set(data.reward_points),
|
||||||
tags: Set(data.tags.clone()),
|
tags: Set(data.tags.clone()),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::response::{ImportResult, IpcResponse};
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateStudentData {
|
pub struct CreateStudentData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -64,6 +65,7 @@ pub async fn student_query(
|
|||||||
.map(|s| StudentWithTags {
|
.map(|s| StudentWithTags {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
group_name: s.group_name,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
reward_points: s.reward_points,
|
reward_points: s.reward_points,
|
||||||
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
|
tags: serde_json::from_str(&s.tags).unwrap_or_default(),
|
||||||
@@ -117,9 +119,16 @@ pub async fn student_create(
|
|||||||
let now = chrono::Utc::now()
|
let now = chrono::Utc::now()
|
||||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let group_name = data
|
||||||
|
.group_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(|v| v.to_string());
|
||||||
let new_student = students::ActiveModel {
|
let new_student = students::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
|
group_name: Set(group_name),
|
||||||
score: Set(0),
|
score: Set(0),
|
||||||
reward_points: Set(0),
|
reward_points: Set(0),
|
||||||
tags: Set("[]".to_string()),
|
tags: Set("[]".to_string()),
|
||||||
@@ -175,6 +184,14 @@ pub async fn student_update(
|
|||||||
if let Some(name) = data.name {
|
if let Some(name) = data.name {
|
||||||
active.name = Set(name);
|
active.name = Set(name);
|
||||||
}
|
}
|
||||||
|
if let Some(group_name) = data.group_name {
|
||||||
|
let normalized = group_name.trim();
|
||||||
|
active.group_name = if normalized.is_empty() {
|
||||||
|
Set(None)
|
||||||
|
} else {
|
||||||
|
Set(Some(normalized.to_string()))
|
||||||
|
};
|
||||||
|
}
|
||||||
if let Some(score) = data.score {
|
if let Some(score) = data.score {
|
||||||
active.score = Set(score);
|
active.score = Set(score);
|
||||||
}
|
}
|
||||||
@@ -231,6 +248,7 @@ pub async fn student_delete(
|
|||||||
students::Entity::delete(students::ActiveModel {
|
students::Entity::delete(students::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::Set(student.id),
|
id: sea_orm::ActiveValue::Set(student.id),
|
||||||
name: sea_orm::ActiveValue::Unchanged(student.name),
|
name: sea_orm::ActiveValue::Unchanged(student.name),
|
||||||
|
group_name: sea_orm::ActiveValue::Unchanged(student.group_name),
|
||||||
score: sea_orm::ActiveValue::Unchanged(student.score),
|
score: sea_orm::ActiveValue::Unchanged(student.score),
|
||||||
reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points),
|
reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points),
|
||||||
tags: sea_orm::ActiveValue::Unchanged(student.tags),
|
tags: sea_orm::ActiveValue::Unchanged(student.tags),
|
||||||
@@ -317,6 +335,7 @@ pub async fn student_import_from_xlsx(
|
|||||||
let new_student = students::ActiveModel {
|
let new_student = students::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
|
group_name: Set(None),
|
||||||
score: Set(0),
|
score: Set(0),
|
||||||
reward_points: Set(0),
|
reward_points: Set(0),
|
||||||
tags: Set("[]".to_string()),
|
tags: Set("[]".to_string()),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct Model {
|
|||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key)]
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub reward_points: i32,
|
pub reward_points: i32,
|
||||||
pub tags: String,
|
pub tags: String,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ impl Migration {
|
|||||||
Self::create_reward_settings_table(conn, is_sqlite).await?;
|
Self::create_reward_settings_table(conn, is_sqlite).await?;
|
||||||
Self::create_reward_redemptions_table(conn, is_sqlite).await?;
|
Self::create_reward_redemptions_table(conn, is_sqlite).await?;
|
||||||
Self::ensure_students_reward_points_column(conn, is_sqlite).await?;
|
Self::ensure_students_reward_points_column(conn, is_sqlite).await?;
|
||||||
|
Self::ensure_students_group_name_column(conn, is_sqlite).await?;
|
||||||
|
|
||||||
Self::create_indexes(conn, is_sqlite).await?;
|
Self::create_indexes(conn, is_sqlite).await?;
|
||||||
|
|
||||||
@@ -163,6 +164,39 @@ impl Migration {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_students_group_name_column(
|
||||||
|
conn: &impl ConnectionTrait,
|
||||||
|
sqlite: bool,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let db_backend = Self::get_db_backend(sqlite);
|
||||||
|
let alter_sql = "ALTER TABLE students ADD COLUMN group_name TEXT";
|
||||||
|
let result = conn
|
||||||
|
.execute(Statement::from_string(
|
||||||
|
db_backend.clone(),
|
||||||
|
alter_sql.to_string(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Added students.group_name column");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string().to_lowercase();
|
||||||
|
let already_exists = msg.contains("duplicate column")
|
||||||
|
|| msg.contains("already exists")
|
||||||
|
|| msg.contains("duplicate");
|
||||||
|
if already_exists {
|
||||||
|
info!("students.group_name already exists, skip alter");
|
||||||
|
} else {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> {
|
||||||
let indexes = vec![
|
let indexes = vec![
|
||||||
get_create_index_score_events_settlement_id_sql(sqlite),
|
get_create_index_score_events_settlement_id_sql(sqlite),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl StudentRepository {
|
|||||||
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
|
pub async fn find_all(&self) -> Result<Vec<StudentWithTags>, sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let students = sqlx::query_as::<Sqlite, Student>(
|
let students = sqlx::query_as::<Sqlite, Student>(
|
||||||
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
|
r#"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at
|
||||||
FROM students
|
FROM students
|
||||||
ORDER BY score DESC, name ASC"#
|
ORDER BY score DESC, name ASC"#
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ impl StudentRepository {
|
|||||||
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
Ok(students.into_iter().map(StudentWithTags::from).collect())
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let students = sqlx::query_as::<Postgres, Student>(
|
let students = sqlx::query_as::<Postgres, Student>(
|
||||||
r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at
|
r#"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at
|
||||||
FROM students
|
FROM students
|
||||||
ORDER BY score DESC, name ASC"#
|
ORDER BY score DESC, name ASC"#
|
||||||
)
|
)
|
||||||
@@ -59,8 +59,8 @@ impl StudentRepository {
|
|||||||
|
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
|
r#"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at)
|
||||||
VALUES (?, 0, 0, '[]', NULL, ?, ?)"#
|
VALUES (?, NULL, 0, 0, '[]', NULL, ?, ?)"#
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -71,8 +71,8 @@ impl StudentRepository {
|
|||||||
Ok(result.last_insert_rowid() as i32)
|
Ok(result.last_insert_rowid() as i32)
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let result = sqlx::query_scalar::<Postgres, i32>(
|
let result = sqlx::query_scalar::<Postgres, i32>(
|
||||||
r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at)
|
r#"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at)
|
||||||
VALUES ($1, 0, 0, '[]', NULL, $2, $3)
|
VALUES ($1, NULL, 0, 0, '[]', NULL, $2, $3)
|
||||||
RETURNING id"#
|
RETURNING id"#
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
@@ -98,6 +98,15 @@ impl StudentRepository {
|
|||||||
query.push(", name = ");
|
query.push(", name = ");
|
||||||
query.push_bind(name);
|
query.push_bind(name);
|
||||||
}
|
}
|
||||||
|
if let Some(group_name) = &data.group_name {
|
||||||
|
let normalized = group_name.trim();
|
||||||
|
query.push(", group_name = ");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
query.push("NULL");
|
||||||
|
} else {
|
||||||
|
query.push_bind(normalized.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(score) = data.score {
|
if let Some(score) = data.score {
|
||||||
query.push(", score = ");
|
query.push(", score = ");
|
||||||
query.push_bind(score);
|
query.push_bind(score);
|
||||||
@@ -127,6 +136,15 @@ impl StudentRepository {
|
|||||||
query.push(", name = ");
|
query.push(", name = ");
|
||||||
query.push_bind(name);
|
query.push_bind(name);
|
||||||
}
|
}
|
||||||
|
if let Some(group_name) = &data.group_name {
|
||||||
|
let normalized = group_name.trim();
|
||||||
|
query.push(", group_name = ");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
query.push("NULL");
|
||||||
|
} else {
|
||||||
|
query.push_bind(normalized.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(score) = data.score {
|
if let Some(score) = data.score {
|
||||||
query.push(", score = ");
|
query.push(", score = ");
|
||||||
query.push_bind(score);
|
query.push_bind(score);
|
||||||
@@ -156,7 +174,7 @@ impl StudentRepository {
|
|||||||
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
|
pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
let student = sqlx::query_as::<Sqlite, Student>(
|
let student = sqlx::query_as::<Sqlite, Student>(
|
||||||
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
|
"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -179,7 +197,7 @@ impl StudentRepository {
|
|||||||
}
|
}
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
let student = sqlx::query_as::<Postgres, Student>(
|
let student = sqlx::query_as::<Postgres, Student>(
|
||||||
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
|
"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -247,7 +265,7 @@ impl StudentRepository {
|
|||||||
|
|
||||||
for name in &to_insert {
|
for name in &to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES (?, 0, 0, '[]', NULL, ?, ?)"
|
"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES (?, NULL, 0, 0, '[]', NULL, ?, ?)"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -282,7 +300,7 @@ impl StudentRepository {
|
|||||||
|
|
||||||
for name in &to_insert {
|
for name in &to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES ($1, 0, 0, '[]', NULL, $2, $3)"
|
"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES ($1, NULL, 0, 0, '[]', NULL, $2, $3)"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -306,14 +324,14 @@ impl StudentRepository {
|
|||||||
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
|
pub async fn find_by_name(&self, name: &str) -> Result<Option<Student>, sqlx::Error> {
|
||||||
if let Some(pool) = &self.sqlite_pool {
|
if let Some(pool) = &self.sqlite_pool {
|
||||||
sqlx::query_as::<Sqlite, Student>(
|
sqlx::query_as::<Sqlite, Student>(
|
||||||
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
|
"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = ?"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
} else if let Some(pool) = &self.postgres_pool {
|
} else if let Some(pool) = &self.postgres_pool {
|
||||||
sqlx::query_as::<Postgres, Student>(
|
sqlx::query_as::<Postgres, Student>(
|
||||||
"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
|
"SELECT id, name, group_name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = $1"
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod students {
|
|||||||
pub const TABLE: &str = "students";
|
pub const TABLE: &str = "students";
|
||||||
pub const ID: &str = "id";
|
pub const ID: &str = "id";
|
||||||
pub const NAME: &str = "name";
|
pub const NAME: &str = "name";
|
||||||
|
pub const GROUP_NAME: &str = "group_name";
|
||||||
pub const TAGS: &str = "tags";
|
pub const TAGS: &str = "tags";
|
||||||
pub const SCORE: &str = "score";
|
pub const SCORE: &str = "score";
|
||||||
pub const REWARD_POINTS: &str = "reward_points";
|
pub const REWARD_POINTS: &str = "reward_points";
|
||||||
@@ -107,6 +108,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
|||||||
CREATE TABLE IF NOT EXISTS students (
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
group_name TEXT,
|
||||||
tags TEXT DEFAULT '[]',
|
tags TEXT DEFAULT '[]',
|
||||||
score INTEGER DEFAULT 0,
|
score INTEGER DEFAULT 0,
|
||||||
reward_points INTEGER DEFAULT 0,
|
reward_points INTEGER DEFAULT 0,
|
||||||
@@ -121,6 +123,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String {
|
|||||||
CREATE TABLE IF NOT EXISTS students (
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
group_name TEXT,
|
||||||
tags TEXT DEFAULT '[]',
|
tags TEXT DEFAULT '[]',
|
||||||
score INTEGER DEFAULT 0,
|
score INTEGER DEFAULT 0,
|
||||||
reward_points INTEGER DEFAULT 0,
|
reward_points INTEGER DEFAULT 0,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct Student {
|
pub struct Student {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub reward_points: i32,
|
pub reward_points: i32,
|
||||||
pub tags: String,
|
pub tags: String,
|
||||||
@@ -15,6 +16,7 @@ pub struct Student {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StudentUpdate {
|
pub struct StudentUpdate {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub score: Option<i32>,
|
pub score: Option<i32>,
|
||||||
pub reward_points: Option<i32>,
|
pub reward_points: Option<i32>,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
@@ -25,6 +27,7 @@ pub struct StudentUpdate {
|
|||||||
pub struct StudentWithTags {
|
pub struct StudentWithTags {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub reward_points: i32,
|
pub reward_points: i32,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
@@ -37,6 +40,7 @@ impl From<Student> for StudentWithTags {
|
|||||||
Self {
|
Self {
|
||||||
id: student.id,
|
id: student.id,
|
||||||
name: student.name,
|
name: student.name,
|
||||||
|
group_name: student.group_name,
|
||||||
score: student.score,
|
score: student.score,
|
||||||
reward_points: student.reward_points,
|
reward_points: student.reward_points,
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
+33
-5
@@ -21,6 +21,7 @@ import { useResponsive } from "../hooks/useResponsive"
|
|||||||
interface student {
|
interface student {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
group_name?: string | null
|
||||||
score: number
|
score: number
|
||||||
reward_points: number
|
reward_points: number
|
||||||
extra_json?: string | null
|
extra_json?: string | null
|
||||||
@@ -54,7 +55,7 @@ interface rewardSetting {
|
|||||||
cost_points: number
|
cost_points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortType = "alphabet" | "surname" | "score"
|
type SortType = "alphabet" | "surname" | "group" | "score"
|
||||||
type LayoutType = "grouped" | "squareGrid"
|
type LayoutType = "grouped" | "squareGrid"
|
||||||
type SearchKeyboardLayout = "t9" | "qwerty26"
|
type SearchKeyboardLayout = "t9" | "qwerty26"
|
||||||
|
|
||||||
@@ -160,6 +161,11 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
|||||||
return py ? py.toUpperCase() : "#"
|
return py ? py.toUpperCase() : "#"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getGroupName = useCallback(
|
||||||
|
(groupName?: string | null) => groupName?.trim() || t("home.ungrouped"),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
const fetchData = useCallback(async (silent = false) => {
|
const fetchData = useCallback(async (silent = false) => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const requestId = ++fetchRequestIdRef.current
|
const requestId = ++fetchRequestIdRef.current
|
||||||
@@ -438,10 +444,21 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
getDisplayPoints(b) - getDisplayPoints(a) || a.name.localeCompare(b.name, "zh-CN")
|
getDisplayPoints(b) - getDisplayPoints(a) || a.name.localeCompare(b.name, "zh-CN")
|
||||||
)
|
)
|
||||||
|
case "group":
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
const groupA = getGroupName(a.group_name)
|
||||||
|
const groupB = getGroupName(b.group_name)
|
||||||
|
if (groupA === groupB) {
|
||||||
|
return (a.pinyinName || "").localeCompare(b.pinyinName || "")
|
||||||
|
}
|
||||||
|
if (groupA === t("home.ungrouped")) return 1
|
||||||
|
if (groupB === t("home.ungrouped")) return -1
|
||||||
|
return groupA.localeCompare(groupB, "zh-CN")
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
}, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints])
|
}, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints, getGroupName, t])
|
||||||
|
|
||||||
const groupedStudents = useMemo(() => {
|
const groupedStudents = useMemo(() => {
|
||||||
if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) {
|
if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) {
|
||||||
@@ -450,15 +467,25 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
|||||||
|
|
||||||
const groups: Record<string, student[]> = {}
|
const groups: Record<string, student[]> = {}
|
||||||
sortedStudents.forEach((s) => {
|
sortedStudents.forEach((s) => {
|
||||||
const key = sortType === "alphabet" ? s.pinyinFirst || "#" : getSurname(s.name)
|
const key =
|
||||||
|
sortType === "alphabet"
|
||||||
|
? s.pinyinFirst || "#"
|
||||||
|
: sortType === "surname"
|
||||||
|
? getSurname(s.name)
|
||||||
|
: getGroupName(s.group_name)
|
||||||
if (!groups[key]) groups[key] = []
|
if (!groups[key]) groups[key] = []
|
||||||
groups[key].push(s)
|
groups[key].push(s)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
.sort(([a], [b]) => a.localeCompare(b, "zh-CN"))
|
.sort(([a], [b]) => {
|
||||||
|
const ungrouped = t("home.ungrouped")
|
||||||
|
if (a === ungrouped) return 1
|
||||||
|
if (b === ungrouped) return -1
|
||||||
|
return a.localeCompare(b, "zh-CN")
|
||||||
|
})
|
||||||
.map(([key, students]) => ({ key, students }))
|
.map(([key, students]) => ({ key, students }))
|
||||||
}, [sortedStudents, sortType, searchKeyword, layoutType])
|
}, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t])
|
||||||
|
|
||||||
const firstStudentIdByGroup = useMemo(() => {
|
const firstStudentIdByGroup = useMemo(() => {
|
||||||
const result = new Map<string, number>()
|
const result = new Map<string, number>()
|
||||||
@@ -2281,6 +2308,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
|||||||
options={[
|
options={[
|
||||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||||
{ value: "surname", label: t("home.sortBy.surname") },
|
{ value: "surname", label: t("home.sortBy.surname") },
|
||||||
|
{ value: "group", label: t("home.sortBy.group") },
|
||||||
{ value: "score", label: t("home.sortBy.score") },
|
{ value: "score", label: t("home.sortBy.score") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const createXlsxWorker = () => {
|
|||||||
interface student {
|
interface student {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
group_name?: string | null
|
||||||
score: number
|
score: number
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
@@ -34,6 +35,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [xlsxVisible, setXlsxVisible] = useState(false)
|
const [xlsxVisible, setXlsxVisible] = useState(false)
|
||||||
const [tagEditVisible, setTagEditVisible] = useState(false)
|
const [tagEditVisible, setTagEditVisible] = useState(false)
|
||||||
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
||||||
|
const [groupEditVisible, setGroupEditVisible] = useState(false)
|
||||||
|
const [groupEditStudent, setGroupEditStudent] = useState<student | null>(null)
|
||||||
|
const [groupSaving, setGroupSaving] = useState(false)
|
||||||
const [avatarVisible, setAvatarVisible] = useState(false)
|
const [avatarVisible, setAvatarVisible] = useState(false)
|
||||||
const [avatarSaving, setAvatarSaving] = useState(false)
|
const [avatarSaving, setAvatarSaving] = useState(false)
|
||||||
const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
|
const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
|
||||||
@@ -48,6 +52,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const xlsxWorkerRef = useRef<Worker | null>(null)
|
const xlsxWorkerRef = useRef<Worker | null>(null)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const [groupForm] = Form.useForm()
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const isTablet = useIsTablet()
|
const isTablet = useIsTablet()
|
||||||
@@ -89,6 +94,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
group_name: s.group_name ?? null,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
extra_json: s.extra_json ?? null,
|
extra_json: s.extra_json ?? null,
|
||||||
avatarUrl: getAvatarFromExtraJson(s.extra_json),
|
avatarUrl: getAvatarFromExtraJson(s.extra_json),
|
||||||
@@ -135,12 +141,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = values.name.trim()
|
const name = values.name.trim()
|
||||||
|
const groupName =
|
||||||
|
typeof values.group_name === "string" && values.group_name.trim()
|
||||||
|
? values.group_name.trim()
|
||||||
|
: undefined
|
||||||
if (data.some((s) => s.name === name)) {
|
if (data.some((s) => s.name === name)) {
|
||||||
messageApi.warning(t("students.nameExists"))
|
messageApi.warning(t("students.nameExists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await (window as any).api.createStudent({ ...values, name })
|
const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName })
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success(t("students.addSuccess"))
|
messageApi.success(t("students.addSuccess"))
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
@@ -202,6 +212,46 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setAvatarVisible(true)
|
setAvatarVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenGroupEditor = (student: student) => {
|
||||||
|
if (!canEdit) {
|
||||||
|
messageApi.error(t("common.readOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGroupEditStudent(student)
|
||||||
|
groupForm.setFieldsValue({ group_name: student.group_name || "" })
|
||||||
|
setGroupEditVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveGroup = async () => {
|
||||||
|
if (!(window as any).api || !groupEditStudent) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await groupForm.validateFields()
|
||||||
|
const groupName =
|
||||||
|
typeof values.group_name === "string" && values.group_name.trim()
|
||||||
|
? values.group_name.trim()
|
||||||
|
: ""
|
||||||
|
setGroupSaving(true)
|
||||||
|
const res = await (window as any).api.updateStudent(groupEditStudent.id, {
|
||||||
|
group_name: groupName,
|
||||||
|
})
|
||||||
|
if (res?.success) {
|
||||||
|
messageApi.success(t("students.groupSaveSuccess"))
|
||||||
|
setGroupEditVisible(false)
|
||||||
|
setGroupEditStudent(null)
|
||||||
|
groupForm.resetFields()
|
||||||
|
fetchStudents()
|
||||||
|
emitDataUpdated("students")
|
||||||
|
} else {
|
||||||
|
messageApi.error(res?.message || t("students.groupSaveFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
setGroupSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -521,6 +571,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
width: isMobile ? 80 : 100,
|
width: isMobile ? 80 : 100,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("students.group"),
|
||||||
|
dataIndex: "group_name",
|
||||||
|
key: "group_name",
|
||||||
|
width: isMobile ? 90 : 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (groupName?: string | null) => groupName?.trim() || t("students.noGroup"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("students.currentScore"),
|
title: t("students.currentScore"),
|
||||||
dataIndex: "score",
|
dataIndex: "score",
|
||||||
@@ -586,6 +644,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
>
|
>
|
||||||
{t("students.editTags")}
|
{t("students.editTags")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size={isMobile ? "small" : "middle"}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => handleOpenGroupEditor(row)}
|
||||||
|
style={{ padding: isMobile ? "2px 4px" : undefined }}
|
||||||
|
>
|
||||||
|
{t("students.editGroup")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size={isMobile ? "small" : "middle"}
|
size={isMobile ? "small" : "middle"}
|
||||||
@@ -673,6 +740,30 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
>
|
>
|
||||||
<Input placeholder={t("students.namePlaceholder")} />
|
<Input placeholder={t("students.namePlaceholder")} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t("students.group")} name="group_name">
|
||||||
|
<Input placeholder={t("students.groupPlaceholder")} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("students.editGroupTitle", { name: groupEditStudent?.name || "" })}
|
||||||
|
open={groupEditVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setGroupEditVisible(false)
|
||||||
|
setGroupEditStudent(null)
|
||||||
|
groupForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={handleSaveGroup}
|
||||||
|
okText={t("common.save")}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
|
okButtonProps={{ loading: groupSaving, disabled: !groupEditStudent }}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={groupForm} layout="vertical">
|
||||||
|
<Form.Item label={t("students.group")} name="group_name">
|
||||||
|
<Input placeholder={t("students.groupPlaceholder")} />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -390,6 +390,7 @@
|
|||||||
"sortBy": {
|
"sortBy": {
|
||||||
"alphabet": "Name Sort",
|
"alphabet": "Name Sort",
|
||||||
"surname": "Surname Group",
|
"surname": "Surname Group",
|
||||||
|
"group": "Group Sort",
|
||||||
"score": "Points Rank"
|
"score": "Points Rank"
|
||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
@@ -409,6 +410,7 @@
|
|||||||
"reasonPlaceholder": "Enter reason for points change (optional)",
|
"reasonPlaceholder": "Enter reason for points change (optional)",
|
||||||
"preview": "Change Preview",
|
"preview": "Change Preview",
|
||||||
"noReason": "(No reason)",
|
"noReason": "(No reason)",
|
||||||
|
"ungrouped": "Ungrouped",
|
||||||
"category": {
|
"category": {
|
||||||
"others": "Others"
|
"others": "Others"
|
||||||
},
|
},
|
||||||
@@ -445,6 +447,7 @@
|
|||||||
"importList": "Import List",
|
"importList": "Import List",
|
||||||
"addStudent": "Add Student",
|
"addStudent": "Add Student",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"group": "Group",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"currentScore": "Current Points",
|
"currentScore": "Current Points",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
@@ -455,6 +458,7 @@
|
|||||||
"addTitle": "Add Student",
|
"addTitle": "Add Student",
|
||||||
"addConfirm": "Add",
|
"addConfirm": "Add",
|
||||||
"namePlaceholder": "Enter student name",
|
"namePlaceholder": "Enter student name",
|
||||||
|
"groupPlaceholder": "Enter group name (optional)",
|
||||||
"nameRequired": "Please enter name",
|
"nameRequired": "Please enter name",
|
||||||
"nameExists": "Student name already exists",
|
"nameExists": "Student name already exists",
|
||||||
"addSuccess": "Added successfully",
|
"addSuccess": "Added successfully",
|
||||||
@@ -480,6 +484,11 @@
|
|||||||
"noNamesFound": "No importable names found in selected column",
|
"noNamesFound": "No importable names found in selected column",
|
||||||
"importFailed": "Import failed",
|
"importFailed": "Import failed",
|
||||||
"editTagTitle": "Edit Tags - {{name}}",
|
"editTagTitle": "Edit Tags - {{name}}",
|
||||||
|
"editGroup": "Set Group",
|
||||||
|
"editGroupTitle": "Set Group - {{name}}",
|
||||||
|
"groupSaveSuccess": "Group saved",
|
||||||
|
"groupSaveFailed": "Failed to save group",
|
||||||
|
"noGroup": "Ungrouped",
|
||||||
"editAvatarTitle": "Set Avatar - {{name}}",
|
"editAvatarTitle": "Set Avatar - {{name}}",
|
||||||
"avatarUpload": "Upload Image",
|
"avatarUpload": "Upload Image",
|
||||||
"avatarClear": "Clear Avatar",
|
"avatarClear": "Clear Avatar",
|
||||||
|
|||||||
@@ -390,6 +390,7 @@
|
|||||||
"sortBy": {
|
"sortBy": {
|
||||||
"alphabet": "姓名排序",
|
"alphabet": "姓名排序",
|
||||||
"surname": "姓氏分组",
|
"surname": "姓氏分组",
|
||||||
|
"group": "小组排序",
|
||||||
"score": "积分排行"
|
"score": "积分排行"
|
||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
@@ -409,6 +410,7 @@
|
|||||||
"reasonPlaceholder": "输入加分/扣分的原因(可选)",
|
"reasonPlaceholder": "输入加分/扣分的原因(可选)",
|
||||||
"preview": "变更预览",
|
"preview": "变更预览",
|
||||||
"noReason": "(无理由)",
|
"noReason": "(无理由)",
|
||||||
|
"ungrouped": "未分组",
|
||||||
"category": {
|
"category": {
|
||||||
"others": "其他"
|
"others": "其他"
|
||||||
},
|
},
|
||||||
@@ -445,6 +447,7 @@
|
|||||||
"importList": "导入名单",
|
"importList": "导入名单",
|
||||||
"addStudent": "添加学生",
|
"addStudent": "添加学生",
|
||||||
"name": "姓名",
|
"name": "姓名",
|
||||||
|
"group": "小组",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
"currentScore": "当前积分",
|
"currentScore": "当前积分",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
@@ -455,6 +458,7 @@
|
|||||||
"addTitle": "添加学生",
|
"addTitle": "添加学生",
|
||||||
"addConfirm": "添加",
|
"addConfirm": "添加",
|
||||||
"namePlaceholder": "请输入学生姓名",
|
"namePlaceholder": "请输入学生姓名",
|
||||||
|
"groupPlaceholder": "请输入小组名称(可选)",
|
||||||
"nameRequired": "请输入姓名",
|
"nameRequired": "请输入姓名",
|
||||||
"nameExists": "学生姓名已存在",
|
"nameExists": "学生姓名已存在",
|
||||||
"addSuccess": "添加成功",
|
"addSuccess": "添加成功",
|
||||||
@@ -480,6 +484,11 @@
|
|||||||
"noNamesFound": "所选列未解析到可导入的姓名",
|
"noNamesFound": "所选列未解析到可导入的姓名",
|
||||||
"importFailed": "导入失败",
|
"importFailed": "导入失败",
|
||||||
"editTagTitle": "编辑标签 - {{name}}",
|
"editTagTitle": "编辑标签 - {{name}}",
|
||||||
|
"editGroup": "设置小组",
|
||||||
|
"editGroupTitle": "设置小组 - {{name}}",
|
||||||
|
"groupSaveSuccess": "小组保存成功",
|
||||||
|
"groupSaveFailed": "小组保存失败",
|
||||||
|
"noGroup": "未分组",
|
||||||
"editAvatarTitle": "设置头像 - {{name}}",
|
"editAvatarTitle": "设置头像 - {{name}}",
|
||||||
"avatarUpload": "上传图片",
|
"avatarUpload": "上传图片",
|
||||||
"avatarClear": "清除头像",
|
"avatarClear": "清除头像",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const api = {
|
|||||||
invoke("student_query", { params }),
|
invoke("student_query", { params }),
|
||||||
createStudent: (data: {
|
createStudent: (data: {
|
||||||
name: string
|
name: string
|
||||||
|
group_name?: string
|
||||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||||
invoke("student_create", { data }),
|
invoke("student_create", { data }),
|
||||||
updateStudent: (id: number, data: any): Promise<{ success: boolean }> =>
|
updateStudent: (id: number, data: any): Promise<{ success: boolean }> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user