diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 90270fa..6dd6909 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -95,6 +95,7 @@ pub enum ConflictStrategy { #[derive(Debug, Clone, PartialEq, Eq)] struct StudentNormalized { name: String, + group_name: Option, score: i32, reward_points: i32, tags: String, @@ -198,6 +199,7 @@ async fn load_students( row.name.clone(), StudentNormalized { name: row.name, + group_name: row.group_name, score: row.score, reward_points: row.reward_points, tags: normalize_tags(&row.tags), @@ -403,6 +405,7 @@ async fn upsert_student( Some(row) => { let normalized_current = StudentNormalized { name: row.name.clone(), + group_name: row.group_name.clone(), score: row.score, reward_points: row.reward_points, tags: normalize_tags(&row.tags), @@ -415,6 +418,7 @@ async fn upsert_student( } let mut active: students::ActiveModel = row.into(); active.score = Set(data.score); + active.group_name = Set(data.group_name.clone()); active.reward_points = Set(data.reward_points); active.tags = Set(data.tags.clone()); active.extra_json = Set(data.extra_json.clone()); @@ -427,6 +431,7 @@ async fn upsert_student( students::ActiveModel { id: sea_orm::ActiveValue::NotSet, name: Set(data.name.clone()), + group_name: Set(data.group_name.clone()), score: Set(data.score), reward_points: Set(data.reward_points), tags: Set(data.tags.clone()), diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index b7c340c..1a34719 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -17,6 +17,7 @@ use super::response::{ImportResult, IpcResponse}; #[derive(Deserialize)] pub struct CreateStudentData { pub name: String, + pub group_name: Option, } #[derive(Deserialize)] @@ -64,6 +65,7 @@ pub async fn student_query( .map(|s| StudentWithTags { id: s.id, name: s.name, + group_name: s.group_name, score: s.score, reward_points: s.reward_points, tags: serde_json::from_str(&s.tags).unwrap_or_default(), @@ -117,9 +119,16 @@ pub async fn student_create( let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .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 { id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), + group_name: Set(group_name), score: Set(0), reward_points: Set(0), tags: Set("[]".to_string()), @@ -175,6 +184,14 @@ pub async fn student_update( if let Some(name) = data.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 { active.score = Set(score); } @@ -231,6 +248,7 @@ pub async fn student_delete( students::Entity::delete(students::ActiveModel { id: sea_orm::ActiveValue::Set(student.id), name: sea_orm::ActiveValue::Unchanged(student.name), + group_name: sea_orm::ActiveValue::Unchanged(student.group_name), score: sea_orm::ActiveValue::Unchanged(student.score), reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points), tags: sea_orm::ActiveValue::Unchanged(student.tags), @@ -317,6 +335,7 @@ pub async fn student_import_from_xlsx( let new_student = students::ActiveModel { id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), + group_name: Set(None), score: Set(0), reward_points: Set(0), tags: Set("[]".to_string()), diff --git a/src-tauri/src/db/entities/students.rs b/src-tauri/src/db/entities/students.rs index 6a0498a..3e1089d 100644 --- a/src-tauri/src/db/entities/students.rs +++ b/src-tauri/src/db/entities/students.rs @@ -7,6 +7,7 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub name: String, + pub group_name: Option, pub score: i32, pub reward_points: i32, pub tags: String, diff --git a/src-tauri/src/db/migration.rs b/src-tauri/src/db/migration.rs index 157e1bb..52aa9df 100644 --- a/src-tauri/src/db/migration.rs +++ b/src-tauri/src/db/migration.rs @@ -23,6 +23,7 @@ impl Migration { Self::create_reward_settings_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_group_name_column(conn, is_sqlite).await?; Self::create_indexes(conn, is_sqlite).await?; @@ -163,6 +164,39 @@ impl Migration { 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> { let indexes = vec![ get_create_index_score_events_settlement_id_sql(sqlite), diff --git a/src-tauri/src/db/repositories/student_repo.rs b/src-tauri/src/db/repositories/student_repo.rs index f7e633a..11e8724 100644 --- a/src-tauri/src/db/repositories/student_repo.rs +++ b/src-tauri/src/db/repositories/student_repo.rs @@ -30,7 +30,7 @@ impl StudentRepository { pub async fn find_all(&self) -> Result, sqlx::Error> { if let Some(pool) = &self.sqlite_pool { let students = sqlx::query_as::( - 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 ORDER BY score DESC, name ASC"# ) @@ -40,7 +40,7 @@ impl StudentRepository { Ok(students.into_iter().map(StudentWithTags::from).collect()) } else if let Some(pool) = &self.postgres_pool { let students = sqlx::query_as::( - 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 ORDER BY score DESC, name ASC"# ) @@ -59,8 +59,8 @@ impl StudentRepository { if let Some(pool) = &self.sqlite_pool { let result = sqlx::query( - r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) - VALUES (?, 0, 0, '[]', NULL, ?, ?)"# + r#"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at) + VALUES (?, NULL, 0, 0, '[]', NULL, ?, ?)"# ) .bind(name) .bind(&now) @@ -71,8 +71,8 @@ impl StudentRepository { Ok(result.last_insert_rowid() as i32) } else if let Some(pool) = &self.postgres_pool { let result = sqlx::query_scalar::( - r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) - VALUES ($1, 0, 0, '[]', NULL, $2, $3) + r#"INSERT INTO students (name, group_name, score, reward_points, tags, extra_json, created_at, updated_at) + VALUES ($1, NULL, 0, 0, '[]', NULL, $2, $3) RETURNING id"# ) .bind(name) @@ -98,6 +98,15 @@ impl StudentRepository { query.push(", 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 { query.push(", score = "); query.push_bind(score); @@ -127,6 +136,15 @@ impl StudentRepository { query.push(", 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 { query.push(", score = "); query.push_bind(score); @@ -156,7 +174,7 @@ impl StudentRepository { pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> { if let Some(pool) = &self.sqlite_pool { let student = sqlx::query_as::( - "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) .fetch_optional(pool) @@ -179,7 +197,7 @@ impl StudentRepository { } } else if let Some(pool) = &self.postgres_pool { let student = sqlx::query_as::( - "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) .fetch_optional(pool) @@ -247,7 +265,7 @@ impl StudentRepository { for name in &to_insert { 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(&now) @@ -282,7 +300,7 @@ impl StudentRepository { for name in &to_insert { 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(&now) @@ -306,14 +324,14 @@ impl StudentRepository { pub async fn find_by_name(&self, name: &str) -> Result, sqlx::Error> { if let Some(pool) = &self.sqlite_pool { sqlx::query_as::( - "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) .fetch_optional(pool) .await } else if let Some(pool) = &self.postgres_pool { sqlx::query_as::( - "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) .fetch_optional(pool) diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 9368c0b..23529b2 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -13,6 +13,7 @@ pub mod students { pub const TABLE: &str = "students"; pub const ID: &str = "id"; pub const NAME: &str = "name"; + pub const GROUP_NAME: &str = "group_name"; pub const TAGS: &str = "tags"; pub const SCORE: &str = "score"; 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + group_name TEXT, tags TEXT DEFAULT '[]', score 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 ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, + group_name TEXT, tags TEXT DEFAULT '[]', score INTEGER DEFAULT 0, reward_points INTEGER DEFAULT 0, diff --git a/src-tauri/src/models/student.rs b/src-tauri/src/models/student.rs index bb41a58..f1277c8 100644 --- a/src-tauri/src/models/student.rs +++ b/src-tauri/src/models/student.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub struct Student { pub id: i32, pub name: String, + pub group_name: Option, pub score: i32, pub reward_points: i32, pub tags: String, @@ -15,6 +16,7 @@ pub struct Student { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StudentUpdate { pub name: Option, + pub group_name: Option, pub score: Option, pub reward_points: Option, pub tags: Option>, @@ -25,6 +27,7 @@ pub struct StudentUpdate { pub struct StudentWithTags { pub id: i32, pub name: String, + pub group_name: Option, pub score: i32, pub reward_points: i32, pub tags: Vec, @@ -37,6 +40,7 @@ impl From for StudentWithTags { Self { id: student.id, name: student.name, + group_name: student.group_name, score: student.score, reward_points: student.reward_points, tags, diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 42decd2..a085f1d 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -21,6 +21,7 @@ import { useResponsive } from "../hooks/useResponsive" interface student { id: number name: string + group_name?: string | null score: number reward_points: number extra_json?: string | null @@ -54,7 +55,7 @@ interface rewardSetting { cost_points: number } -type SortType = "alphabet" | "surname" | "score" +type SortType = "alphabet" | "surname" | "group" | "score" type LayoutType = "grouped" | "squareGrid" type SearchKeyboardLayout = "t9" | "qwerty26" @@ -160,6 +161,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = return py ? py.toUpperCase() : "#" } + const getGroupName = useCallback( + (groupName?: string | null) => groupName?.trim() || t("home.ungrouped"), + [t] + ) + const fetchData = useCallback(async (silent = false) => { if (!(window as any).api) return const requestId = ++fetchRequestIdRef.current @@ -438,10 +444,21 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = (a, b) => 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: return filtered } - }, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints]) + }, [students, searchKeyword, sortType, matchStudentName, getDisplayPoints, getGroupName, t]) const groupedStudents = useMemo(() => { if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) { @@ -450,15 +467,25 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const groups: Record = {} 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] = [] groups[key].push(s) }) 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 })) - }, [sortedStudents, sortType, searchKeyword, layoutType]) + }, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t]) const firstStudentIdByGroup = useMemo(() => { const result = new Map() @@ -2281,6 +2308,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = options={[ { value: "alphabet", label: t("home.sortBy.alphabet") }, { value: "surname", label: t("home.sortBy.surname") }, + { value: "group", label: t("home.sortBy.group") }, { value: "score", label: t("home.sortBy.score") }, ]} /> diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index abe96fd..6b2ccac 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -16,6 +16,7 @@ const createXlsxWorker = () => { interface student { id: number name: string + group_name?: string | null score: number tags?: string[] tagIds?: number[] @@ -34,6 +35,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [xlsxVisible, setXlsxVisible] = useState(false) const [tagEditVisible, setTagEditVisible] = useState(false) const [editingStudent, setEditingStudent] = useState(null) + const [groupEditVisible, setGroupEditVisible] = useState(false) + const [groupEditStudent, setGroupEditStudent] = useState(null) + const [groupSaving, setGroupSaving] = useState(false) const [avatarVisible, setAvatarVisible] = useState(false) const [avatarSaving, setAvatarSaving] = useState(false) const [avatarStudent, setAvatarStudent] = useState(null) @@ -48,6 +52,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const xlsxInputRef = useRef(null) const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() + const [groupForm] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const isMobile = useIsMobile() const isTablet = useIsTablet() @@ -89,6 +94,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { return { id: s.id, name: s.name, + group_name: s.group_name ?? null, score: s.score, extra_json: s.extra_json ?? null, avatarUrl: getAvatarFromExtraJson(s.extra_json), @@ -135,12 +141,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } 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)) { messageApi.warning(t("students.nameExists")) 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) { messageApi.success(t("students.addSuccess")) setVisible(false) @@ -202,6 +212,46 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { 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 => { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -521,6 +571,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { width: isMobile ? 80 : 100, 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"), dataIndex: "score", @@ -586,6 +644,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { > {t("students.editTags")} +