mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 新增班优导入并修复班级头像显示
This commit is contained in:
Generated
+218
-4
@@ -1463,8 +1463,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1474,9 +1476,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1827,6 +1831,23 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2338,6 +2359,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3342,6 +3369,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3394,6 +3476,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -3425,6 +3517,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3443,6 +3545,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -3560,6 +3671,44 @@ dependencies = [
|
|||||||
"bytecheck",
|
"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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3752,10 +3901,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-webpki",
|
"rustls-webpki 0.101.7",
|
||||||
"sct",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3765,6 +3928,16 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.101.7"
|
version = "0.101.7"
|
||||||
@@ -3775,6 +3948,17 @@ dependencies = [
|
|||||||
"untrusted",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3985,6 +4169,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4494,7 +4679,7 @@ dependencies = [
|
|||||||
"paste",
|
"paste",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"rustls",
|
"rustls 0.21.12",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4508,7 +4693,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webpki-roots",
|
"webpki-roots 0.25.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4929,7 +5114,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -5318,6 +5503,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -5969,6 +6164,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -6031,6 +6236,15 @@ version = "0.25.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ tracing-appender = "0.2"
|
|||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = { version = "0.12", features = ["send_guard"] }
|
parking_lot = { version = "0.12", features = ["send_guard"] }
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ use parking_lot::RwLock;
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::db::entities::students;
|
use crate::db::entities::students;
|
||||||
use crate::models::{StudentUpdate, StudentWithTags};
|
use crate::models::{StudentUpdate, StudentWithTags};
|
||||||
|
use crate::services::logger::LogLevel;
|
||||||
use crate::services::PermissionLevel;
|
use crate::services::PermissionLevel;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -25,6 +26,49 @@ pub struct ImportStudentsParams {
|
|||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub master_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub students_num: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub praise_count: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub class_avatar_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub class_avatar_data_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_own: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BanYouClassroomFetchData {
|
||||||
|
#[serde(default)]
|
||||||
|
pub classrooms: Vec<BanYouClassroom>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub administrative_groups: Vec<BanYouClassroom>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BanYouClassroomFetchApiResponse {
|
||||||
|
pub code: i32,
|
||||||
|
pub data: Option<BanYouClassroomFetchData>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>) -> bool {
|
||||||
let state_guard = state.read();
|
let state_guard = state.read();
|
||||||
let mut permissions = state_guard.permissions.write();
|
let mut permissions = state_guard.permissions.write();
|
||||||
@@ -39,6 +83,80 @@ fn check_view_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>)
|
|||||||
permissions.require_permission(id, PermissionLevel::View)
|
permissions.require_permission(id, PermissionLevel::View)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_banyou(
|
||||||
|
state: &Arc<RwLock<AppState>>,
|
||||||
|
level: LogLevel,
|
||||||
|
message: &str,
|
||||||
|
meta: Option<serde_json::Value>,
|
||||||
|
) {
|
||||||
|
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<String> {
|
||||||
|
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<String, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub async fn student_query(
|
pub async fn student_query(
|
||||||
state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
@@ -362,3 +480,273 @@ pub async fn student_import_from_xlsx(
|
|||||||
Ok(IpcResponse::error("Database not connected"))
|
Ok(IpcResponse::error("Database not connected"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn student_fetch_banyou_classrooms(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
params: FetchBanYouClassroomsParams,
|
||||||
|
) -> Result<IpcResponse<BanYouClassroomFetchData>, 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub fn run() {
|
|||||||
student_update,
|
student_update,
|
||||||
student_delete,
|
student_delete,
|
||||||
student_import_from_xlsx,
|
student_import_from_xlsx,
|
||||||
|
student_fetch_banyou_classrooms,
|
||||||
tags_get_all,
|
tags_get_all,
|
||||||
tags_get_by_student,
|
tags_get_by_student,
|
||||||
tags_create,
|
tags_create,
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ interface student {
|
|||||||
avatarUrl?: string | null
|
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 }) => {
|
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
const UNGROUPED_KEY = "__ungrouped__"
|
const UNGROUPED_KEY = "__ungrouped__"
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -32,8 +44,10 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState<number>(50)
|
const [pageSize, setPageSize] = useState<number>(50)
|
||||||
const [visible, setVisible] = useState(false)
|
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 [xlsxVisible, setXlsxVisible] = useState(false)
|
||||||
|
const [banYouVisible, setBanYouVisible] = 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 [groupEditVisible, setGroupEditVisible] = useState(false)
|
||||||
@@ -63,6 +77,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
||||||
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
||||||
const [textImportValue, setTextImportValue] = useState("")
|
const [textImportValue, setTextImportValue] = useState("")
|
||||||
|
const [banYouCookie, setBanYouCookie] = useState("")
|
||||||
|
const [banYouLoading, setBanYouLoading] = useState(false)
|
||||||
|
const [banYouClassrooms, setBanYouClassrooms] = useState<BanYouClassroom[]>([])
|
||||||
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()
|
||||||
@@ -559,7 +576,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setXlsxAoa(event.data.data)
|
setXlsxAoa(event.data.data)
|
||||||
setXlsxSelectedCol(null)
|
setXlsxSelectedCol(null)
|
||||||
setXlsxVisible(true)
|
setXlsxVisible(true)
|
||||||
setImportVisible(false)
|
setImportOptionsVisible(false)
|
||||||
|
setTextImportVisible(false)
|
||||||
setXlsxLoading(false)
|
setXlsxLoading(false)
|
||||||
} else if (event.data.type === "error") {
|
} else if (event.data.type === "error") {
|
||||||
messageApi.error(event.data.error || t("students.parseXlsxFailed"))
|
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)
|
const success = await importNames(names)
|
||||||
if (!success) return
|
if (!success) return
|
||||||
setTextImportValue("")
|
setTextImportValue("")
|
||||||
setImportVisible(false)
|
setTextImportVisible(false)
|
||||||
} finally {
|
} finally {
|
||||||
setTextImportLoading(false)
|
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<student> = useMemo(() => {
|
const columns: ColumnsType<student> = useMemo(() => {
|
||||||
const baseColumns: ColumnsType<student> = [
|
const baseColumns: ColumnsType<student> = [
|
||||||
{
|
{
|
||||||
@@ -889,7 +980,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<Button disabled={!canEdit} onClick={openGroupBoardEditor}>
|
<Button disabled={!canEdit} onClick={openGroupBoardEditor}>
|
||||||
{t("students.groupBoardEdit")}
|
{t("students.groupBoardEdit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
<Button disabled={!canEdit} onClick={handleOpenImportOptions}>
|
||||||
{t("students.importList")}
|
{t("students.importList")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||||
@@ -1145,9 +1236,29 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={t("students.importTitle")}
|
title={t("students.importOptionsTitle")}
|
||||||
open={importVisible}
|
open={importOptionsVisible}
|
||||||
onCancel={() => setImportVisible(false)}
|
onCancel={() => setImportOptionsVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||||
|
<Button type="primary" disabled={!canEdit} onClick={handleOpenTextImport}>
|
||||||
|
{t("students.importByText")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={xlsxLoading} disabled={!canEdit} onClick={handleOpenXlsxImport}>
|
||||||
|
{t("students.importByXlsx")}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canEdit} onClick={handleOpenBanYouImport}>
|
||||||
|
{t("students.importByBanyou")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("students.importByText")}
|
||||||
|
open={textImportVisible}
|
||||||
|
onCancel={() => setTextImportVisible(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
@@ -1168,31 +1279,182 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onClick={handleImportTextNames}
|
onClick={handleImportTextNames}
|
||||||
>
|
>
|
||||||
{t("students.importByText")}
|
{t("students.importConfirm")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
loading={xlsxLoading}
|
|
||||||
disabled={!canEdit}
|
|
||||||
onClick={() => {
|
|
||||||
xlsxInputRef.current?.click()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("students.importByXlsx")}
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={xlsxInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) parseXlsxFile(file)
|
|
||||||
if (xlsxInputRef.current) xlsxInputRef.current.value = ""
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("students.importByBanyou")}
|
||||||
|
open={banYouVisible}
|
||||||
|
onCancel={() => setBanYouVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={900}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: "100%" }} size={12}>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||||
|
{t("students.banyouCookieHint")}
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
value={banYouCookie}
|
||||||
|
onChange={(e) => setBanYouCookie(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder={t("students.banyouCookiePlaceholder")}
|
||||||
|
disabled={banYouLoading}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={banYouLoading} onClick={handleFetchBanYouClassrooms}>
|
||||||
|
{t("students.banyouFetch")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<h3 style={{ margin: "0 0 12px", color: "var(--ss-text-main)" }}>
|
||||||
|
{t("students.banyouCreatedClasses")}
|
||||||
|
</h3>
|
||||||
|
{banYouCreatedClasses.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(320px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{banYouCreatedClasses.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`created-${item.classId}`}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "var(--ss-card-bg)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 14px",
|
||||||
|
borderBottom: "1px solid var(--ss-border-color)",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--ss-text-main)",
|
||||||
|
fontSize: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 14, display: "flex", gap: 12 }}>
|
||||||
|
{item.classAvatarDataUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.classAvatarDataUrl}
|
||||||
|
alt={item.classNickName}
|
||||||
|
style={{ width: 72, height: 72, borderRadius: "50%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "var(--ss-bg-color)",
|
||||||
|
border: "1px dashed var(--ss-border-color)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, color: "var(--ss-text-main)" }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
{t("students.banyouClassTeacher")}
|
||||||
|
{item.masterName || "-"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
||||||
|
{t("students.banyouStudentCount")}
|
||||||
|
{Number(item.studentsNum ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 48,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--ss-text-main)",
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(item.praiseCount ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
||||||
|
{t("students.banyouNoClasses")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<h3 style={{ margin: "0 0 12px", color: "var(--ss-text-main)" }}>
|
||||||
|
{t("students.banyouJoinedClasses")}
|
||||||
|
</h3>
|
||||||
|
{banYouJoinedClasses.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(320px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{banYouJoinedClasses.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`joined-${item.classId}`}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "var(--ss-card-bg)",
|
||||||
|
padding: 14,
|
||||||
|
color: "var(--ss-text-main)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 8 }}>
|
||||||
|
{item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
||||||
|
{t("students.banyouClassTeacher")}
|
||||||
|
{item.masterName || "-"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
||||||
|
{t("students.banyouStudentCount")}
|
||||||
|
{Number(item.studentsNum ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
||||||
|
{t("students.banyouPraiseCount")}
|
||||||
|
{Number(item.praiseCount ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
||||||
|
{t("students.banyouNoClasses")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={xlsxInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) parseXlsxFile(file)
|
||||||
|
if (xlsxInputRef.current) xlsxInputRef.current.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={t("students.xlsxPreview")}
|
title={t("students.xlsxPreview")}
|
||||||
open={xlsxVisible}
|
open={xlsxVisible}
|
||||||
|
|||||||
@@ -468,10 +468,12 @@
|
|||||||
"tagSaveSuccess": "Tags saved successfully",
|
"tagSaveSuccess": "Tags saved successfully",
|
||||||
"tagSaveFailed": "Failed to save tags",
|
"tagSaveFailed": "Failed to save tags",
|
||||||
"importTitle": "Import List",
|
"importTitle": "Import List",
|
||||||
|
"importOptionsTitle": "Choose Import Method",
|
||||||
"importTextHint": "Paste multiple lines, one student name per line",
|
"importTextHint": "Paste multiple lines, one student name per line",
|
||||||
"importTextPlaceholder": "Example:\nAlice\nBob\nCharlie",
|
"importTextPlaceholder": "Example:\nAlice\nBob\nCharlie",
|
||||||
"importByText": "Import from Text",
|
"importByText": "Import from Text",
|
||||||
"importByXlsx": "Import via xlsx",
|
"importByXlsx": "Import via xlsx",
|
||||||
|
"importByBanyou": "Import from BanYou",
|
||||||
"xlsxPreview": "XLSX Preview & Import",
|
"xlsxPreview": "XLSX Preview & Import",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
"selectNameCol": "Click header to select name column",
|
"selectNameCol": "Click header to select name column",
|
||||||
@@ -483,6 +485,18 @@
|
|||||||
"selectNameColFirst": "Please select \"Name Column\" first",
|
"selectNameColFirst": "Please select \"Name Column\" first",
|
||||||
"noNamesFound": "No importable names found in selected column",
|
"noNamesFound": "No importable names found in selected column",
|
||||||
"importFailed": "Import failed",
|
"importFailed": "Import failed",
|
||||||
|
"banyouCookieHint": "After signing in to BanYou Web, copy the full Cookie and paste it below.",
|
||||||
|
"banyouCookiePlaceholder": "uid=...; accessToken=...; ...",
|
||||||
|
"banyouCookieRequired": "Please paste Cookie first",
|
||||||
|
"banyouFetch": "Fetch Classrooms",
|
||||||
|
"banyouFetchSuccess": "Fetched {{count}} classrooms",
|
||||||
|
"banyouFetchFailed": "Failed to fetch BanYou classrooms",
|
||||||
|
"banyouCreatedClasses": "Classes I Created",
|
||||||
|
"banyouJoinedClasses": "Classes I Joined",
|
||||||
|
"banyouNoClasses": "No classroom data",
|
||||||
|
"banyouClassTeacher": "Teacher: ",
|
||||||
|
"banyouStudentCount": "Students: ",
|
||||||
|
"banyouPraiseCount": "Praise: ",
|
||||||
"editTagTitle": "Edit Tags - {{name}}",
|
"editTagTitle": "Edit Tags - {{name}}",
|
||||||
"editGroup": "Set Group",
|
"editGroup": "Set Group",
|
||||||
"editGroupTitle": "Set Group - {{name}}",
|
"editGroupTitle": "Set Group - {{name}}",
|
||||||
|
|||||||
@@ -468,10 +468,12 @@
|
|||||||
"tagSaveSuccess": "标签保存成功",
|
"tagSaveSuccess": "标签保存成功",
|
||||||
"tagSaveFailed": "保存标签失败",
|
"tagSaveFailed": "保存标签失败",
|
||||||
"importTitle": "导入名单",
|
"importTitle": "导入名单",
|
||||||
|
"importOptionsTitle": "选择导入方式",
|
||||||
"importTextHint": "支持粘贴多行文本,一行一个姓名",
|
"importTextHint": "支持粘贴多行文本,一行一个姓名",
|
||||||
"importTextPlaceholder": "例如:\n张三\n李四\n王五",
|
"importTextPlaceholder": "例如:\n张三\n李四\n王五",
|
||||||
"importByText": "从文本导入",
|
"importByText": "从文本导入",
|
||||||
"importByXlsx": "通过 xlsx 导入",
|
"importByXlsx": "通过 xlsx 导入",
|
||||||
|
"importByBanyou": "从班优导入",
|
||||||
"xlsxPreview": "xlsx 预览与导入",
|
"xlsxPreview": "xlsx 预览与导入",
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
"selectNameCol": "点击表头选择姓名列",
|
"selectNameCol": "点击表头选择姓名列",
|
||||||
@@ -483,6 +485,18 @@
|
|||||||
"selectNameColFirst": "请先点击选择\"姓名列\"",
|
"selectNameColFirst": "请先点击选择\"姓名列\"",
|
||||||
"noNamesFound": "所选列未解析到可导入的姓名",
|
"noNamesFound": "所选列未解析到可导入的姓名",
|
||||||
"importFailed": "导入失败",
|
"importFailed": "导入失败",
|
||||||
|
"banyouCookieHint": "请在班优网页版登录后,复制完整 Cookie 并粘贴到下方。",
|
||||||
|
"banyouCookiePlaceholder": "uid=...; accessToken=...; ...",
|
||||||
|
"banyouCookieRequired": "请先粘贴 Cookie",
|
||||||
|
"banyouFetch": "获取班级列表",
|
||||||
|
"banyouFetchSuccess": "已获取 {{count}} 个班级",
|
||||||
|
"banyouFetchFailed": "获取班优班级失败",
|
||||||
|
"banyouCreatedClasses": "我创建的班级",
|
||||||
|
"banyouJoinedClasses": "我加入的班级",
|
||||||
|
"banyouNoClasses": "暂无班级数据",
|
||||||
|
"banyouClassTeacher": "班主任:",
|
||||||
|
"banyouStudentCount": "学生:",
|
||||||
|
"banyouPraiseCount": "表扬:",
|
||||||
"editTagTitle": "编辑标签 - {{name}}",
|
"editTagTitle": "编辑标签 - {{name}}",
|
||||||
"editGroup": "设置小组",
|
"editGroup": "设置小组",
|
||||||
"editGroupTitle": "设置小组 - {{name}}",
|
"editGroupTitle": "设置小组 - {{name}}",
|
||||||
|
|||||||
@@ -89,6 +89,34 @@ const api = {
|
|||||||
names: string[]
|
names: string[]
|
||||||
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
|
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
|
||||||
invoke("student_import_from_xlsx", { params }),
|
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
|
// DB - Tags
|
||||||
tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"),
|
tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"),
|
||||||
|
|||||||
Reference in New Issue
Block a user