diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f43691..c89fa74 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1463,8 +1463,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1474,9 +1476,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1827,6 +1831,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2338,6 +2359,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -3342,6 +3369,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3394,6 +3476,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -3425,6 +3517,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3443,6 +3545,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.0" @@ -3560,6 +3671,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3752,10 +3901,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.10", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -3765,6 +3928,16 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -3775,6 +3948,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3985,6 +4169,7 @@ dependencies = [ "once_cell", "parking_lot", "rand 0.8.5", + "reqwest 0.12.28", "rmcp", "sea-orm", "serde", @@ -4494,7 +4679,7 @@ dependencies = [ "paste", "percent-encoding", "rust_decimal", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -4508,7 +4693,7 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -4929,7 +5114,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -5318,6 +5503,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -5969,6 +6164,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" @@ -6031,6 +6236,15 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a566963..3a1fdd9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ tracing-appender = "0.2" once_cell = "1" parking_lot = { version = "0.12", features = ["send_guard"] } dirs = "6.0.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [profile.release] panic = "abort" diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index 1a34719..c0e2875 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -2,12 +2,13 @@ use parking_lot::RwLock; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use crate::db::entities::students; use crate::models::{StudentUpdate, StudentWithTags}; +use crate::services::logger::LogLevel; use crate::services::PermissionLevel; use crate::state::AppState; @@ -25,6 +26,49 @@ pub struct ImportStudentsParams { pub names: Vec, } +#[derive(Deserialize)] +pub struct FetchBanYouClassroomsParams { + pub cookie: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BanYouClassroom { + pub class_id: String, + #[serde(default)] + pub class_nick_name: String, + #[serde(default)] + pub invitation_code: Option, + #[serde(default)] + pub master_name: Option, + #[serde(default)] + pub students_num: Option, + #[serde(default)] + pub praise_count: Option, + #[serde(default)] + pub class_avatar_path: Option, + #[serde(default)] + pub class_avatar_data_url: Option, + #[serde(default)] + pub is_own: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BanYouClassroomFetchData { + #[serde(default)] + pub classrooms: Vec, + #[serde(default)] + pub administrative_groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct BanYouClassroomFetchApiResponse { + pub code: i32, + pub data: Option, + pub message: Option, +} + fn check_admin_permission(state: &Arc>, sender_id: Option) -> bool { let state_guard = state.read(); let mut permissions = state_guard.permissions.write(); @@ -39,6 +83,80 @@ fn check_view_permission(state: &Arc>, sender_id: Option) permissions.require_permission(id, PermissionLevel::View) } +fn log_banyou( + state: &Arc>, + level: LogLevel, + message: &str, + meta: Option, +) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.log(level, message, Some("student:banyou"), meta); +} + +fn first_n_chars(text: &str, n: usize) -> String { + text.chars().take(n).collect() +} + +fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> { + let from = text.find(start)?; + let from_idx = from + start.len(); + let tail = &text[from_idx..]; + let to = tail.find(end)?; + Some(&tail[..to]) +} + +fn extract_csrf_token_from_html(html: &str) -> Option { + let patterns = [ + ("name=\"csrf-token\" content=\"", "\""), + ("name='csrf-token' content='", "'"), + ("\"csrfToken\":\"", "\""), + ("'csrfToken':'", "'"), + ("csrfToken:\"", "\""), + ("csrfToken:'", "'"), + ]; + for (start, end) in patterns { + if let Some(token) = extract_between(html, start, end) { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +async fn fetch_image_as_data_url( + client: &reqwest::Client, + image_url: &str, + cookie: &str, +) -> Result { + let response = client + .get(image_url) + .header(reqwest::header::REFERER, "https://care.seewo.com/app/") + .header(reqwest::header::ORIGIN, "https://care.seewo.com") + .header(reqwest::header::COOKIE, cookie) + .send() + .await + .map_err(|e| e.to_string())?; + + let status = response.status(); + if !status.is_success() { + return Err(format!("http status {}", status.as_u16())); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + let base64_data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + Ok(format!("data:{};base64,{}", content_type, base64_data)) +} + #[tauri::command] pub async fn student_query( state: State<'_, Arc>>, @@ -362,3 +480,273 @@ pub async fn student_import_from_xlsx( Ok(IpcResponse::error("Database not connected")) } } + +#[tauri::command] +pub async fn student_fetch_banyou_classrooms( + state: State<'_, Arc>>, + sender_id: Option, + params: FetchBanYouClassroomsParams, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let cookie = params.cookie.trim(); + if cookie.is_empty() { + log_banyou( + state.inner(), + LogLevel::Warn, + "fetch classrooms rejected: empty cookie", + None, + ); + return Ok(IpcResponse::error("Cookie cannot be empty")); + } + log_banyou( + state.inner(), + LogLevel::Info, + "fetch classrooms started", + Some(serde_json::json!({ + "cookie_length": cookie.len(), + "contains_uid": cookie.contains("uid="), + "contains_access_token": cookie.contains("accessToken="), + })), + ); + + 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 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( + 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(); + + for classroom in &mut data.classrooms { + classroom.class_avatar_data_url = None; + if let Some(url) = classroom.class_avatar_path.clone() { + match fetch_image_as_data_url(&client, &url, cookie).await { + Ok(data_url) => { + classroom.class_avatar_data_url = Some(data_url); + } + Err(err) => { + log_banyou( + state.inner(), + LogLevel::Warn, + "failed to fetch classroom avatar", + Some(serde_json::json!({ + "class_id": classroom.class_id, + "url": url, + "error": err, + })), + ); + } + } + } + } + + for classroom in &mut data.administrative_groups { + classroom.class_avatar_data_url = None; + if let Some(url) = classroom.class_avatar_path.clone() { + match fetch_image_as_data_url(&client, &url, cookie).await { + Ok(data_url) => { + classroom.class_avatar_data_url = Some(data_url); + } + Err(err) => { + log_banyou( + state.inner(), + LogLevel::Warn, + "failed to fetch administrative group avatar", + Some(serde_json::json!({ + "class_id": classroom.class_id, + "url": url, + "error": err, + })), + ); + } + } + } + } + + log_banyou( + state.inner(), + LogLevel::Info, + "fetch classrooms succeeded", + Some(serde_json::json!({ + "classrooms": data.classrooms.len(), + "administrative_groups": data.administrative_groups.len(), + "classrooms_avatar_data_url_count": data + .classrooms + .iter() + .filter(|c| c.class_avatar_data_url.is_some()) + .count(), + })), + ); + Ok(IpcResponse::success(data)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4cc014f..ab5d9c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ pub fn run() { student_update, student_delete, student_import_from_xlsx, + student_fetch_banyou_classrooms, tags_get_all, tags_get_by_student, tags_create, diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index 2e31dd9..c9e4504 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -24,6 +24,18 @@ interface student { avatarUrl?: string | null } +interface BanYouClassroom { + classId: string + classNickName: string + invitationCode?: string | null + masterName?: string | null + studentsNum?: number | null + praiseCount?: number | null + classAvatarPath?: string | null + classAvatarDataUrl?: string | null + isOwn?: boolean | null +} + export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const UNGROUPED_KEY = "__ungrouped__" const { t } = useTranslation() @@ -32,8 +44,10 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(50) const [visible, setVisible] = useState(false) - const [importVisible, setImportVisible] = useState(false) + const [importOptionsVisible, setImportOptionsVisible] = useState(false) + const [textImportVisible, setTextImportVisible] = useState(false) const [xlsxVisible, setXlsxVisible] = useState(false) + const [banYouVisible, setBanYouVisible] = useState(false) const [tagEditVisible, setTagEditVisible] = useState(false) const [editingStudent, setEditingStudent] = useState(null) const [groupEditVisible, setGroupEditVisible] = useState(false) @@ -63,6 +77,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [xlsxAoa, setXlsxAoa] = useState([]) const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null) const [textImportValue, setTextImportValue] = useState("") + const [banYouCookie, setBanYouCookie] = useState("") + const [banYouLoading, setBanYouLoading] = useState(false) + const [banYouClassrooms, setBanYouClassrooms] = useState([]) const xlsxInputRef = useRef(null) const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() @@ -559,7 +576,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setXlsxAoa(event.data.data) setXlsxSelectedCol(null) setXlsxVisible(true) - setImportVisible(false) + setImportOptionsVisible(false) + setTextImportVisible(false) setXlsxLoading(false) } else if (event.data.type === "error") { messageApi.error(event.data.error || t("students.parseXlsxFailed")) @@ -714,12 +732,85 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const success = await importNames(names) if (!success) return setTextImportValue("") - setImportVisible(false) + setTextImportVisible(false) } finally { setTextImportLoading(false) } } + const handleOpenImportOptions = () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setImportOptionsVisible(true) + } + + const handleOpenTextImport = () => { + setImportOptionsVisible(false) + setTextImportVisible(true) + } + + const handleOpenXlsxImport = () => { + setImportOptionsVisible(false) + xlsxInputRef.current?.click() + } + + const handleOpenBanYouImport = () => { + setImportOptionsVisible(false) + setBanYouVisible(true) + } + + const handleFetchBanYouClassrooms = async () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + const cookie = banYouCookie.trim() + if (!cookie) { + messageApi.warning(t("students.banyouCookieRequired")) + return + } + + setBanYouLoading(true) + try { + console.debug("[BanYou] fetch classrooms start", { + cookieLength: cookie.length, + containsUid: cookie.includes("uid="), + containsAccessToken: cookie.includes("accessToken="), + }) + const res = await (window as any).api.fetchBanYouClassrooms({ cookie }) + if (!res?.success || !res?.data) { + console.error("[BanYou] fetch classrooms failed", res) + messageApi.error(res?.message || t("students.banyouFetchFailed")) + return + } + const classrooms = Array.isArray(res.data.classrooms) ? res.data.classrooms : [] + console.debug("[BanYou] fetch classrooms success", { + classrooms: classrooms.length, + administrativeGroups: Array.isArray(res.data.administrativeGroups) + ? res.data.administrativeGroups.length + : 0, + }) + setBanYouClassrooms(classrooms) + messageApi.success(t("students.banyouFetchSuccess", { count: classrooms.length })) + } catch (e: any) { + console.error("[BanYou] fetch classrooms exception", e) + messageApi.error(e?.message || t("students.banyouFetchFailed")) + } finally { + setBanYouLoading(false) + } + } + + const banYouCreatedClasses = useMemo( + () => banYouClassrooms.filter((item) => item.isOwn !== false), + [banYouClassrooms] + ) + const banYouJoinedClasses = useMemo( + () => banYouClassrooms.filter((item) => item.isOwn === false), + [banYouClassrooms] + ) + const columns: ColumnsType = useMemo(() => { const baseColumns: ColumnsType = [ { @@ -889,7 +980,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { - + + + + + + setTextImportVisible(false)} footer={null} destroyOnHidden > @@ -1168,31 +1279,182 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { disabled={!canEdit} onClick={handleImportTextNames} > - {t("students.importByText")} + {t("students.importConfirm")} - - { - const file = e.target.files?.[0] - if (file) parseXlsxFile(file) - if (xlsxInputRef.current) xlsxInputRef.current.value = "" - }} - /> + setBanYouVisible(false)} + footer={null} + width={900} + destroyOnHidden + > + +
+ {t("students.banyouCookieHint")} +
+ setBanYouCookie(e.target.value)} + rows={4} + placeholder={t("students.banyouCookiePlaceholder")} + disabled={banYouLoading} + /> + + +
+

+ {t("students.banyouCreatedClasses")} +

+ {banYouCreatedClasses.length > 0 ? ( +
+ {banYouCreatedClasses.map((item) => ( +
+
+ {item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""} +
+
+ {item.classAvatarDataUrl ? ( + {item.classNickName} + ) : ( +
+ )} +
+
+ {t("students.banyouClassTeacher")} + {item.masterName || "-"} +
+
+ {t("students.banyouStudentCount")} + {Number(item.studentsNum ?? 0)} +
+
+
+ {Number(item.praiseCount ?? 0)} +
+
+
+ ))} +
+ ) : ( +
+ {t("students.banyouNoClasses")} +
+ )} +
+ +
+

+ {t("students.banyouJoinedClasses")} +

+ {banYouJoinedClasses.length > 0 ? ( +
+ {banYouJoinedClasses.map((item) => ( +
+
+ {item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""} +
+
+ {t("students.banyouClassTeacher")} + {item.masterName || "-"} +
+
+ {t("students.banyouStudentCount")} + {Number(item.studentsNum ?? 0)} +
+
+ {t("students.banyouPraiseCount")} + {Number(item.praiseCount ?? 0)} +
+
+ ))} +
+ ) : ( +
+ {t("students.banyouNoClasses")} +
+ )} +
+ + + + { + const file = e.target.files?.[0] + if (file) parseXlsxFile(file) + if (xlsxInputRef.current) xlsxInputRef.current.value = "" + }} + /> + => invoke("student_import_from_xlsx", { params }), + fetchBanYouClassrooms: (params: { cookie: string }): Promise<{ + success: boolean + data?: { + classrooms: Array<{ + classId: string + classNickName: string + invitationCode?: string | null + masterName?: string | null + studentsNum?: number | null + praiseCount?: number | null + classAvatarPath?: string | null + classAvatarDataUrl?: string | null + isOwn?: boolean | null + }> + administrativeGroups: Array<{ + classId: string + classNickName: string + invitationCode?: string | null + masterName?: string | null + studentsNum?: number | null + praiseCount?: number | null + classAvatarPath?: string | null + classAvatarDataUrl?: string | null + isOwn?: boolean | null + }> + } + message?: string + }> => invoke("student_fetch_banyou_classrooms", { params }), // DB - Tags tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"),