diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index c0e2875..48d3027 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -2,6 +2,7 @@ use parking_lot::RwLock; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; @@ -31,6 +32,14 @@ pub struct FetchBanYouClassroomsParams { pub cookie: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchBanYouClassroomDetailParams { + pub cookie: String, + pub class_id: String, + pub team_plan_id: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct BanYouClassroom { @@ -62,10 +71,114 @@ pub struct BanYouClassroomFetchData { pub administrative_groups: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouMedal { + #[serde(default)] + pub key: String, + #[serde(default)] + pub uid: String, + #[serde(default)] + pub name: String, + #[serde(rename = "type", default)] + pub medal_type: i32, + #[serde(default)] + pub value: i32, + #[serde(default)] + pub avatar: Option, + #[serde(default)] + pub custom_avatar: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouStudentItem { + #[serde(default)] + pub student_id: String, + #[serde(default)] + pub student_name: String, + #[serde(default)] + pub avatar: Option, + #[serde(default)] + pub student_avatar: Option, + #[serde(default)] + pub custom_avatar: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouTeamItem { + #[serde(default)] + pub team_id: String, + #[serde(default)] + pub team_name: String, + #[serde(default)] + pub students: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouStudentFetchData { + #[serde(default)] + pub students: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouGroupFetchData { + #[serde(default)] + pub teams: Vec, + #[serde(default)] + pub students: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouClassroomDetailData { + #[serde(default)] + pub medals: Vec, + #[serde(default)] + pub students: Vec, + #[serde(default)] + pub teams: Vec, + #[serde(default)] + pub ungrouped_students: Vec, + #[serde(default)] + pub team_plan_id_used: Option, + #[serde(default)] + pub team_plans: Vec, + #[serde(default)] + pub team_plan_source: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouTeamPlanOption { + pub team_plan_id: i64, + #[serde(default)] + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct BanYouGroupCollectionPlan { + #[serde(default)] + pub team_plan_id: i64, + #[serde(default)] + pub plan_name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct BanYouGroupCollectionData { + #[serde(default)] + pub class_team_plans: Vec, +} + #[derive(Debug, Deserialize)] -struct BanYouClassroomFetchApiResponse { +struct BanYouApiResponse { pub code: i32, - pub data: Option, + pub data: Option, pub message: Option, } @@ -126,6 +239,211 @@ fn extract_csrf_token_from_html(html: &str) -> Option { None } +async fn get_banyou_client_and_csrf( + state: &Arc>, + cookie: &str, +) -> Result<(reqwest::Client, Option), String> { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + .build() + .map_err(|e| { + log_banyou( + state, + LogLevel::Error, + "failed to build reqwest client", + Some(serde_json::json!({ "error": e.to_string() })), + ); + e.to_string() + })?; + + let csrf_token = match client + .get("https://care.seewo.com/app/") + .header(reqwest::header::COOKIE, cookie) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + match resp.text().await { + Ok(html) => { + let token = extract_csrf_token_from_html(&html); + log_banyou( + state, + LogLevel::Info, + "csrf token fetch finished", + Some(serde_json::json!({ + "status": status.as_u16(), + "html_length": html.len(), + "csrf_found": token.is_some(), + })), + ); + token + } + Err(e) => { + log_banyou( + state, + LogLevel::Warn, + "failed to read app html when fetching csrf token", + Some(serde_json::json!({ "error": e.to_string() })), + ); + None + } + } + } + Err(e) => { + log_banyou( + state, + LogLevel::Warn, + "failed to request app html for csrf token", + Some(serde_json::json!({ "error": e.to_string() })), + ); + None + } + }; + + Ok((client, csrf_token)) +} + +async fn post_banyou_action( + state: &Arc>, + client: &reqwest::Client, + cookie: &str, + csrf_token: Option<&str>, + action: &str, + params: serde_json::Value, +) -> Result { + let timestamp = chrono::Utc::now().timestamp_millis(); + let url = format!("https://care.seewo.com/app/apis.json?action={action}×tamp={timestamp}&isAjax=1"); + let payload = serde_json::json!({ + "action": action, + "params": params, + }); + + let mut request = client + .post(url) + .header( + reqwest::header::ACCEPT, + "application/json, text/javascript, */*; q=0.01", + ) + .header(reqwest::header::ACCEPT_LANGUAGE, "zh-CN,zh;q=0.9") + .header(reqwest::header::CACHE_CONTROL, "no-cache") + .header(reqwest::header::PRAGMA, "no-cache") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ORIGIN, "https://care.seewo.com") + .header(reqwest::header::REFERER, "https://care.seewo.com/app/") + .header("X-Requested-With", "XMLHttpRequest") + .header(reqwest::header::COOKIE, cookie) + .json(&payload); + + if let Some(token) = csrf_token { + request = request.header("x-csrf-token", token); + } + + let response = request.send().await.map_err(|e| { + log_banyou( + state, + LogLevel::Error, + "http request failed", + Some(serde_json::json!({ "action": action, "error": e.to_string() })), + ); + e.to_string() + })?; + + let status = response.status(); + let body = response.text().await.map_err(|e| { + log_banyou( + state, + LogLevel::Error, + "failed to read response body", + Some(serde_json::json!({ "action": action, "error": e.to_string() })), + ); + e.to_string() + })?; + + if !status.is_success() { + log_banyou( + state, + LogLevel::Error, + "non-success http status from banyou", + Some(serde_json::json!({ + "action": action, + "status": status.as_u16(), + "body_preview": first_n_chars(&body, 500), + })), + ); + return Err(format!("班优接口请求失败({} HTTP {})", action, status.as_u16())); + } + + let parsed: BanYouApiResponse = serde_json::from_str(&body).map_err(|e| { + log_banyou( + state, + LogLevel::Error, + "failed to parse banyou response json", + Some(serde_json::json!({ + "action": action, + "error": e.to_string(), + "body_preview": first_n_chars(&body, 500), + })), + ); + format!("Failed to parse BanYou response: {}", e) + })?; + + if parsed.code != 200 { + let msg = parsed + .message + .unwrap_or_else(|| format!("BanYou API returned code {}", parsed.code)); + log_banyou( + state, + LogLevel::Warn, + "banyou api returned non-200 business code", + Some(serde_json::json!({ + "action": action, + "code": parsed.code, + "message": msg, + })), + ); + return Err(msg); + } + + parsed + .data + .ok_or_else(|| format!("班优接口返回空数据({})", action)) +} + +async fn try_fetch_team_plans( + state: &Arc>, + client: &reqwest::Client, + cookie: &str, + csrf_token: Option<&str>, + class_id: &str, +) -> (Vec, Option) { + if let Ok(data) = post_banyou_action::( + state, + client, + cookie, + csrf_token, + "GROUP_COLLECTION_GET_LIST", + serde_json::json!({ "classId": class_id, "originKey": "easicare-web" }), + ) + .await + { + let mut list: Vec = data + .class_team_plans + .into_iter() + .filter(|p| p.team_plan_id > 0) + .map(|p| BanYouTeamPlanOption { + team_plan_id: p.team_plan_id, + name: p.plan_name, + }) + .collect(); + if !list.is_empty() { + list.sort_by_key(|x| x.team_plan_id); + return (list, Some("GROUP_COLLECTION_GET_LIST".to_string())); + } + } + (Vec::new(), None) +} + async fn fetch_image_as_data_url( client: &reqwest::Client, image_url: &str, @@ -512,181 +830,24 @@ pub async fn student_fetch_banyou_classrooms( })), ); - let timestamp = chrono::Utc::now().timestamp_millis(); - let url = format!( - "https://care.seewo.com/app/apis.json?action=CLASSROOM_FETCH×tamp={timestamp}&isAjax=1" - ); - let payload = serde_json::json!({ - "action": "CLASSROOM_FETCH", - "params": { - "originKey": "easicare-web" - } - }); - - let client = reqwest::Client::builder() - .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - .build() - .map_err(|e| { - log_banyou( - state.inner(), - LogLevel::Error, - "failed to build reqwest client", - Some(serde_json::json!({ "error": e.to_string() })), - ); - e.to_string() - })?; - - let csrf_token = match client - .get("https://care.seewo.com/app/") - .header(reqwest::header::COOKIE, cookie) - .send() - .await - { - Ok(resp) => { - let status = resp.status(); - match resp.text().await { - Ok(html) => { - let token = extract_csrf_token_from_html(&html); - log_banyou( - state.inner(), - LogLevel::Info, - "csrf token fetch finished", - Some(serde_json::json!({ - "status": status.as_u16(), - "html_length": html.len(), - "csrf_found": token.is_some(), - })), - ); - token - } - Err(e) => { - log_banyou( - state.inner(), - LogLevel::Warn, - "failed to read app html when fetching csrf token", - Some(serde_json::json!({ "error": e.to_string() })), - ); - None - } - } - } - Err(e) => { - log_banyou( - state.inner(), - LogLevel::Warn, - "failed to request app html for csrf token", - Some(serde_json::json!({ "error": e.to_string() })), - ); - None - } + let (client, csrf_token) = match get_banyou_client_and_csrf(state.inner(), cookie).await { + Ok(v) => v, + Err(err) => return Ok(IpcResponse::error(&err)), }; - let mut request = client - .post(url) - .header( - reqwest::header::ACCEPT, - "application/json, text/javascript, */*; q=0.01", - ) - .header(reqwest::header::ACCEPT_LANGUAGE, "zh-CN,zh;q=0.9") - .header(reqwest::header::CACHE_CONTROL, "no-cache") - .header(reqwest::header::PRAGMA, "no-cache") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::ORIGIN, "https://care.seewo.com") - .header(reqwest::header::REFERER, "https://care.seewo.com/app/") - .header("X-Requested-With", "XMLHttpRequest") - .header(reqwest::header::COOKIE, cookie) - .json(&payload); - - if let Some(token) = csrf_token.clone() { - request = request.header("x-csrf-token", token); - } - - log_banyou( + let mut data: BanYouClassroomFetchData = match post_banyou_action( state.inner(), - LogLevel::Info, - "sending classroom fetch request", - Some(serde_json::json!({ - "csrf_attached": csrf_token.is_some(), - })), - ); - - let resp = request.send().await.map_err(|e| { - log_banyou( - state.inner(), - LogLevel::Error, - "http request failed", - Some(serde_json::json!({ "error": e.to_string() })), - ); - e.to_string() - })?; - - let status = resp.status(); - let body = resp.text().await.map_err(|e| { - log_banyou( - state.inner(), - LogLevel::Error, - "failed to read response body", - Some(serde_json::json!({ "error": e.to_string() })), - ); - e.to_string() - })?; - log_banyou( - state.inner(), - LogLevel::Info, - "http response received", - Some(serde_json::json!({ - "status": status.as_u16(), - "body_length": body.len(), - "body_preview": first_n_chars(&body, 500), - })), - ); - - if !status.is_success() { - log_banyou( - state.inner(), - LogLevel::Error, - "non-success http status from banyou", - Some(serde_json::json!({ - "status": status.as_u16(), - "body_preview": first_n_chars(&body, 500), - })), - ); - return Ok(IpcResponse::error(&format!( - "班优接口请求失败(HTTP {})", - status.as_u16() - ))); - } - - let parsed: BanYouClassroomFetchApiResponse = serde_json::from_str(&body) - .map_err(|e| { - log_banyou( - state.inner(), - LogLevel::Error, - "failed to parse banyou response json", - Some(serde_json::json!({ - "error": e.to_string(), - "body_preview": first_n_chars(&body, 500), - })), - ); - format!("Failed to parse BanYou response: {}", e) - })?; - if parsed.code != 200 { - let msg = parsed - .message - .unwrap_or_else(|| format!("BanYou API returned code {}", parsed.code)); - log_banyou( - state.inner(), - LogLevel::Warn, - "banyou api returned non-200 business code", - Some(serde_json::json!({ - "code": parsed.code, - "message": msg, - })), - ); - return Ok(IpcResponse::error(&msg)); - } - - let mut data = parsed.data.unwrap_or_default(); + &client, + cookie, + csrf_token.as_deref(), + "CLASSROOM_FETCH", + serde_json::json!({ "originKey": "easicare-web" }), + ) + .await + { + Ok(v) => v, + Err(err) => return Ok(IpcResponse::error(&err)), + }; for classroom in &mut data.classrooms { classroom.class_avatar_data_url = None; @@ -750,3 +911,152 @@ pub async fn student_fetch_banyou_classrooms( ); Ok(IpcResponse::success(data)) } + +#[tauri::command] +pub async fn student_fetch_banyou_classroom_detail( + state: State<'_, Arc>>, + sender_id: Option, + params: FetchBanYouClassroomDetailParams, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let cookie = params.cookie.trim(); + let class_id = params.class_id.trim(); + if cookie.is_empty() { + return Ok(IpcResponse::error("Cookie cannot be empty")); + } + if class_id.is_empty() { + return Ok(IpcResponse::error("Class ID cannot be empty")); + } + + log_banyou( + state.inner(), + LogLevel::Info, + "fetch classroom detail started", + Some(serde_json::json!({ + "class_id": class_id, + "team_plan_id": params.team_plan_id, + })), + ); + + let (client, csrf_token) = match get_banyou_client_and_csrf(state.inner(), cookie).await { + Ok(v) => v, + Err(err) => return Ok(IpcResponse::error(&err)), + }; + + let medals: Vec = match post_banyou_action( + state.inner(), + &client, + cookie, + csrf_token.as_deref(), + "MEDAL_FETCH_BY_CLASSROOM", + serde_json::json!({ + "cid": class_id, + "originKey": "easicare-web", + }), + ) + .await + { + Ok(v) => v, + Err(err) => return Ok(IpcResponse::error(&err)), + }; + + let students_data: BanYouStudentFetchData = match post_banyou_action( + state.inner(), + &client, + cookie, + csrf_token.as_deref(), + "STUDENT_FETCH_LIST", + serde_json::json!({ + "classroomId": class_id, + "originKey": "easicare-web", + }), + ) + .await + { + Ok(v) => v, + Err(err) => return Ok(IpcResponse::error(&err)), + }; + + let (team_plans, team_plan_source) = if params.team_plan_id.is_none() { + try_fetch_team_plans( + state.inner(), + &client, + cookie, + csrf_token.as_deref(), + class_id, + ) + .await + } else { + (Vec::new(), None) + }; + + let final_team_plan_id = params.team_plan_id.or_else(|| team_plans.first().map(|p| p.team_plan_id)); + + let group_data = if let Some(team_plan_id) = final_team_plan_id { + match post_banyou_action::( + state.inner(), + &client, + cookie, + csrf_token.as_deref(), + "GROUP_FETCH_LIST", + serde_json::json!({ + "classroomId": class_id, + "teamPlanId": team_plan_id, + "originKey": "easicare-web", + }), + ) + .await + { + Ok(v) => Some(v), + Err(err) => { + log_banyou( + state.inner(), + LogLevel::Warn, + "group fetch failed", + Some(serde_json::json!({ + "class_id": class_id, + "team_plan_id": team_plan_id, + "error": err, + })), + ); + None + } + } + } else { + None + }; + + let detail = BanYouClassroomDetailData { + medals, + students: students_data.students, + teams: group_data.as_ref().map(|d| d.teams.clone()).unwrap_or_default(), + ungrouped_students: group_data + .as_ref() + .map(|d| d.students.clone()) + .unwrap_or_default(), + team_plan_id_used: final_team_plan_id, + team_plans, + team_plan_source, + }; + + log_banyou( + state.inner(), + LogLevel::Info, + "fetch classroom detail succeeded", + Some(serde_json::json!({ + "class_id": class_id, + "medals": detail.medals.len(), + "students": detail.students.len(), + "teams": detail.teams.len(), + "ungrouped_students": detail.ungrouped_students.len(), + "team_plans": detail.team_plans.len(), + "team_plan_id_used": detail.team_plan_id_used, + "team_plan_source": detail.team_plan_source, + })), + ); + + Ok(IpcResponse::success(detail)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab5d9c4..15b3e06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub fn run() { student_delete, student_import_from_xlsx, student_fetch_banyou_classrooms, + student_fetch_banyou_classroom_detail, tags_get_all, tags_get_by_student, tags_create, diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index c9e4504..7273070 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -1,5 +1,18 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from "react" -import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination, Dropdown } from "antd" +import { + Table, + Button, + Space, + message, + Modal, + Form, + Input, + Tag, + Pagination, + Dropdown, + Checkbox, + Select, +} from "antd" import type { ColumnsType } from "antd/es/table" import { UploadOutlined, MoreOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" @@ -36,6 +49,41 @@ interface BanYouClassroom { isOwn?: boolean | null } +interface BanYouMedal { + key?: string + uid?: string + name: string + type?: number + medalType?: number + value?: number +} + +interface BanYouStudentItem { + studentId: string + studentName: string +} + +interface BanYouTeamItem { + teamId: string + teamName: string + students: BanYouStudentItem[] +} + +interface BanYouTeamPlanOption { + teamPlanId: number + name?: string +} + +interface BanYouClassroomDetail { + medals: BanYouMedal[] + students: BanYouStudentItem[] + teams: BanYouTeamItem[] + ungroupedStudents: BanYouStudentItem[] + teamPlanIdUsed?: number + teamPlans?: BanYouTeamPlanOption[] + teamPlanSource?: string +} + export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const UNGROUPED_KEY = "__ungrouped__" const { t } = useTranslation() @@ -79,7 +127,19 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [textImportValue, setTextImportValue] = useState("") const [banYouCookie, setBanYouCookie] = useState("") const [banYouLoading, setBanYouLoading] = useState(false) + const [banYouDetailLoading, setBanYouDetailLoading] = useState(false) + const [banYouImportLoading, setBanYouImportLoading] = useState(false) const [banYouClassrooms, setBanYouClassrooms] = useState([]) + const [banYouDetailVisible, setBanYouDetailVisible] = useState(false) + const [banYouSelectedClass, setBanYouSelectedClass] = useState(null) + const [banYouDetail, setBanYouDetail] = useState(null) + const [banYouCheckedMedals, setBanYouCheckedMedals] = useState([]) + const [banYouCheckedStudents, setBanYouCheckedStudents] = useState([]) + const [banYouCheckedTeams, setBanYouCheckedTeams] = useState([]) + const [banYouTeamPlanOptions, setBanYouTeamPlanOptions] = useState([]) + const [banYouSelectedTeamPlanId, setBanYouSelectedTeamPlanId] = useState( + undefined + ) const xlsxInputRef = useRef(null) const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() @@ -672,19 +732,20 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { return extractUniqueNames(lines) } - const importNames = async (names: string[]) => { - if (!(window as any).api) return false + const importNames = async (names: string[], options?: { toast?: boolean }) => { + const toast = options?.toast ?? true + if (!(window as any).api) return { success: false, inserted: 0, skipped: 0 } const res = await (window as any).api.importStudentsFromXlsx({ names }) if (!res?.success) { - messageApi.error(res?.message || t("students.importFailed")) - return false + if (toast) messageApi.error(res?.message || t("students.importFailed")) + return { success: false, inserted: 0, skipped: 0 } } const inserted = Number(res?.data?.inserted ?? 0) const skipped = Number(res?.data?.skipped ?? 0) - messageApi.success(t("students.importComplete", { inserted, skipped })) + if (toast) messageApi.success(t("students.importComplete", { inserted, skipped })) fetchStudents() emitDataUpdated("students") - return true + return { success: true, inserted, skipped } } const handleConfirmXlsxImport = async () => { @@ -705,8 +766,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setXlsxLoading(true) try { - const success = await importNames(names) - if (!success) return + const result = await importNames(names) + if (!result.success) return setXlsxVisible(false) setXlsxAoa([]) setXlsxFileName("") @@ -729,8 +790,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setTextImportLoading(true) try { - const success = await importNames(names) - if (!success) return + const result = await importNames(names) + if (!result.success) return setTextImportValue("") setTextImportVisible(false) } finally { @@ -802,6 +863,185 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } } + const medalKey = (item: BanYouMedal, idx: number) => item.key || item.uid || `${item.name}-${idx}` + + const fetchBanYouClassDetail = async (classroom: BanYouClassroom, teamPlanId?: number) => { + const cookie = banYouCookie.trim() + if (!cookie) { + messageApi.warning(t("students.banyouCookieRequired")) + return + } + setBanYouSelectedClass(classroom) + setBanYouDetailVisible(true) + setBanYouDetailLoading(true) + try { + const parsedTeamPlanId = typeof teamPlanId === "number" ? teamPlanId : undefined + const params: any = { + cookie, + classId: classroom.classId, + } + if (Number.isFinite(parsedTeamPlanId)) params.teamPlanId = parsedTeamPlanId + const res = await (window as any).api.fetchBanYouClassroomDetail(params) + if (!res?.success || !res?.data) { + messageApi.error(res?.message || t("students.banyouFetchDetailFailed")) + setBanYouDetail(null) + return + } + const detail = res.data as BanYouClassroomDetail + setBanYouDetail(detail) + const options = Array.isArray(detail.teamPlans) ? detail.teamPlans : [] + setBanYouTeamPlanOptions(options) + const used = Number(detail.teamPlanIdUsed) + if (Number.isFinite(used) && used > 0) { + setBanYouSelectedTeamPlanId(used) + } else if (options.length > 0) { + setBanYouSelectedTeamPlanId(Number(options[0].teamPlanId)) + } else { + setBanYouSelectedTeamPlanId(undefined) + } + setBanYouCheckedStudents((detail.students || []).map((s) => s.studentId)) + setBanYouCheckedMedals((detail.medals || []).map((m, idx) => medalKey(m, idx))) + setBanYouCheckedTeams((detail.teams || []).map((team) => team.teamId)) + } catch (e: any) { + messageApi.error(e?.message || t("students.banyouFetchDetailFailed")) + setBanYouDetail(null) + } finally { + setBanYouDetailLoading(false) + } + } + + const handleOpenBanYouClassDetail = async (classroom: BanYouClassroom) => { + await fetchBanYouClassDetail(classroom) + } + + const handleReloadBanYouGroupByPlan = async () => { + if (!banYouSelectedClass) return + if (!banYouSelectedTeamPlanId) { + messageApi.warning(t("students.banyouSelectTeamPlanFirst")) + return + } + await fetchBanYouClassDetail(banYouSelectedClass, banYouSelectedTeamPlanId) + } + + const handleImportBanYouSelected = async () => { + if (!banYouDetail) return + if (!(window as any).api) return + + const selectedStudents = (banYouDetail.students || []).filter((s) => + banYouCheckedStudents.includes(s.studentId) + ) + const selectedMedals = (banYouDetail.medals || []).filter((m, idx) => + banYouCheckedMedals.includes(medalKey(m, idx)) + ) + const selectedTeams = (banYouDetail.teams || []).filter((g) => banYouCheckedTeams.includes(g.teamId)) + + if (!selectedStudents.length && !selectedMedals.length && !selectedTeams.length) { + messageApi.warning(t("students.banyouNothingSelected")) + return + } + + setBanYouImportLoading(true) + try { + let studentInserted = 0 + let studentSkipped = 0 + if (selectedStudents.length) { + const names = selectedStudents.map((s) => s.studentName) + const res = await importNames(names, { toast: false }) + if (!res.success) { + messageApi.error(t("students.importFailed")) + return + } + studentInserted = res.inserted + studentSkipped = res.skipped + } + + let reasonInserted = 0 + let reasonSkipped = 0 + if (selectedMedals.length) { + const reasonCategory = "班优导入" + const currentReasons = await (window as any).api.queryReasons() + const existing = new Set() + if (currentReasons?.success && Array.isArray(currentReasons?.data)) { + for (const r of currentReasons.data) { + existing.add(`${r.category}::${r.content}::${Number(r.delta)}`) + } + } + for (const medal of selectedMedals) { + const content = String(medal.name || "").trim() + if (!content) { + reasonSkipped += 1 + continue + } + const medalType = Number(medal.medalType ?? medal.type ?? 1) + const value = Math.abs(Number(medal.value ?? 1)) || 1 + const delta = medalType < 0 ? -value : value + const key = `${reasonCategory}::${content}::${delta}` + if (existing.has(key)) { + reasonSkipped += 1 + continue + } + const createRes = await (window as any).api.createReason({ + content, + category: reasonCategory, + delta, + }) + if (createRes?.success) { + reasonInserted += 1 + existing.add(key) + } else { + reasonSkipped += 1 + } + } + } + + let groupUpdated = 0 + let groupSkipped = 0 + if (selectedTeams.length) { + const mapping = new Map() + for (const team of selectedTeams) { + for (const student of team.students || []) { + const name = String(student.studentName || "").trim() + if (name && !mapping.has(name)) mapping.set(name, team.teamName || "") + } + } + if (mapping.size > 0) { + const localStudentsRes = await (window as any).api.queryStudents({}) + const localMap = new Map() + if (localStudentsRes?.success && Array.isArray(localStudentsRes?.data)) { + for (const s of localStudentsRes.data) { + localMap.set(String(s.name), Number(s.id)) + } + } + for (const [name, groupName] of mapping.entries()) { + const id = localMap.get(name) + if (!id) { + groupSkipped += 1 + continue + } + const updateRes = await (window as any).api.updateStudent(id, { group_name: groupName }) + if (updateRes?.success) groupUpdated += 1 + else groupSkipped += 1 + } + } + } + + emitDataUpdated("all") + fetchStudents() + messageApi.success( + t("students.banyouImportSummary", { + studentsInserted: studentInserted, + studentsSkipped: studentSkipped, + reasonsInserted: reasonInserted, + reasonsSkipped: reasonSkipped, + groupsUpdated: groupUpdated, + groupsSkipped: groupSkipped, + }) + ) + } finally { + setBanYouImportLoading(false) + } + } + const banYouCreatedClasses = useMemo( () => banYouClassrooms.filter((item) => item.isOwn !== false), [banYouClassrooms] @@ -1322,11 +1562,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {banYouCreatedClasses.map((item) => (
handleOpenBanYouClassDetail(item)} style={{ border: "1px solid var(--ss-border-color)", borderRadius: 12, backgroundColor: "var(--ss-card-bg)", overflow: "hidden", + cursor: "pointer", }} >
= ({ canEdit }) => { {banYouJoinedClasses.map((item) => (
handleOpenBanYouClassDetail(item)} style={{ border: "1px solid var(--ss-border-color)", borderRadius: 12, backgroundColor: "var(--ss-card-bg)", padding: 14, color: "var(--ss-text-main)", + cursor: "pointer", }} >
@@ -1443,6 +1687,161 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + { + setBanYouDetailVisible(false) + setBanYouDetail(null) + setBanYouSelectedClass(null) + setBanYouTeamPlanOptions([]) + setBanYouSelectedTeamPlanId(undefined) + }} + width={980} + destroyOnHidden + onOk={handleImportBanYouSelected} + okText={t("students.banyouImportSelected")} + okButtonProps={{ loading: banYouImportLoading, disabled: !banYouDetail }} + cancelText={t("common.cancel")} + > + {banYouDetailLoading ? ( +
+ {t("common.loading")} +
+ ) : !banYouDetail ? ( +
{t("students.banyouDetailEmpty")}
+ ) : ( +
+ {banYouTeamPlanOptions.length > 0 && ( +
+
+ {t("students.banyouTeamPlanSelector")} +
+ + => invoke("student_fetch_banyou_classrooms", { params }), + fetchBanYouClassroomDetail: (params: { + cookie: string + classId: string + teamPlanId?: number + }): Promise<{ + success: boolean + data?: { + medals: Array<{ + key?: string + uid?: string + name: string + type?: number + medalType?: number + value?: number + }> + students: Array<{ + studentId: string + studentName: string + avatar?: string | null + }> + teams: Array<{ + teamId: string + teamName: string + students: Array<{ + studentId: string + studentName: string + }> + }> + ungroupedStudents: Array<{ + studentId: string + studentName: string + }> + teamPlanIdUsed?: number + teamPlans?: Array<{ + teamPlanId: number + name?: string + }> + teamPlanSource?: string + } + message?: string + }> => invoke("student_fetch_banyou_classroom_detail", { params }), // DB - Tags tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"),