mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
refactor: 班优分组方案仅使用已知接口并隐藏手动teamPlanId
This commit is contained in:
+484
-174
@@ -2,6 +2,7 @@ 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::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -31,6 +32,14 @@ pub struct FetchBanYouClassroomsParams {
|
|||||||
pub cookie: String,
|
pub cookie: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FetchBanYouClassroomDetailParams {
|
||||||
|
pub cookie: String,
|
||||||
|
pub class_id: String,
|
||||||
|
pub team_plan_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BanYouClassroom {
|
pub struct BanYouClassroom {
|
||||||
@@ -62,10 +71,114 @@ pub struct BanYouClassroomFetchData {
|
|||||||
pub administrative_groups: Vec<BanYouClassroom>,
|
pub administrative_groups: Vec<BanYouClassroom>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_avatar: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub student_avatar: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_avatar: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<BanYouStudentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BanYouStudentFetchData {
|
||||||
|
#[serde(default)]
|
||||||
|
pub students: Vec<BanYouStudentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BanYouGroupFetchData {
|
||||||
|
#[serde(default)]
|
||||||
|
pub teams: Vec<BanYouTeamItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub students: Vec<BanYouStudentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BanYouClassroomDetailData {
|
||||||
|
#[serde(default)]
|
||||||
|
pub medals: Vec<BanYouMedal>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub students: Vec<BanYouStudentItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub teams: Vec<BanYouTeamItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ungrouped_students: Vec<BanYouStudentItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub team_plan_id_used: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub team_plans: Vec<BanYouTeamPlanOption>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub team_plan_source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<BanYouGroupCollectionPlan>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BanYouClassroomFetchApiResponse {
|
struct BanYouApiResponse<T> {
|
||||||
pub code: i32,
|
pub code: i32,
|
||||||
pub data: Option<BanYouClassroomFetchData>,
|
pub data: Option<T>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +239,211 @@ fn extract_csrf_token_from_html(html: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_banyou_client_and_csrf(
|
||||||
|
state: &Arc<RwLock<AppState>>,
|
||||||
|
cookie: &str,
|
||||||
|
) -> Result<(reqwest::Client, Option<String>), 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<T: DeserializeOwned>(
|
||||||
|
state: &Arc<RwLock<AppState>>,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
cookie: &str,
|
||||||
|
csrf_token: Option<&str>,
|
||||||
|
action: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
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<T> = 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<RwLock<AppState>>,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
cookie: &str,
|
||||||
|
csrf_token: Option<&str>,
|
||||||
|
class_id: &str,
|
||||||
|
) -> (Vec<BanYouTeamPlanOption>, Option<String>) {
|
||||||
|
if let Ok(data) = post_banyou_action::<BanYouGroupCollectionData>(
|
||||||
|
state,
|
||||||
|
client,
|
||||||
|
cookie,
|
||||||
|
csrf_token,
|
||||||
|
"GROUP_COLLECTION_GET_LIST",
|
||||||
|
serde_json::json!({ "classId": class_id, "originKey": "easicare-web" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let mut list: Vec<BanYouTeamPlanOption> = 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(
|
async fn fetch_image_as_data_url(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
image_url: &str,
|
image_url: &str,
|
||||||
@@ -512,181 +830,24 @@ pub async fn student_fetch_banyou_classrooms(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
let (client, csrf_token) = match get_banyou_client_and_csrf(state.inner(), cookie).await {
|
||||||
let url = format!(
|
Ok(v) => v,
|
||||||
"https://care.seewo.com/app/apis.json?action=CLASSROOM_FETCH×tamp={timestamp}&isAjax=1"
|
Err(err) => return Ok(IpcResponse::error(&err)),
|
||||||
);
|
|
||||||
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
|
let mut data: BanYouClassroomFetchData = match post_banyou_action(
|
||||||
.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(),
|
state.inner(),
|
||||||
LogLevel::Info,
|
&client,
|
||||||
"sending classroom fetch request",
|
cookie,
|
||||||
Some(serde_json::json!({
|
csrf_token.as_deref(),
|
||||||
"csrf_attached": csrf_token.is_some(),
|
"CLASSROOM_FETCH",
|
||||||
})),
|
serde_json::json!({ "originKey": "easicare-web" }),
|
||||||
);
|
)
|
||||||
|
.await
|
||||||
let resp = request.send().await.map_err(|e| {
|
{
|
||||||
log_banyou(
|
Ok(v) => v,
|
||||||
state.inner(),
|
Err(err) => return Ok(IpcResponse::error(&err)),
|
||||||
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 {
|
for classroom in &mut data.classrooms {
|
||||||
classroom.class_avatar_data_url = None;
|
classroom.class_avatar_data_url = None;
|
||||||
@@ -750,3 +911,152 @@ pub async fn student_fetch_banyou_classrooms(
|
|||||||
);
|
);
|
||||||
Ok(IpcResponse::success(data))
|
Ok(IpcResponse::success(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn student_fetch_banyou_classroom_detail(
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
params: FetchBanYouClassroomDetailParams,
|
||||||
|
) -> Result<IpcResponse<BanYouClassroomDetailData>, 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<BanYouMedal> = 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::<BanYouGroupFetchData>(
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub fn run() {
|
|||||||
student_delete,
|
student_delete,
|
||||||
student_import_from_xlsx,
|
student_import_from_xlsx,
|
||||||
student_fetch_banyou_classrooms,
|
student_fetch_banyou_classrooms,
|
||||||
|
student_fetch_banyou_classroom_detail,
|
||||||
tags_get_all,
|
tags_get_all,
|
||||||
tags_get_by_student,
|
tags_get_by_student,
|
||||||
tags_create,
|
tags_create,
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
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 type { ColumnsType } from "antd/es/table"
|
||||||
import { UploadOutlined, MoreOutlined } from "@ant-design/icons"
|
import { UploadOutlined, MoreOutlined } from "@ant-design/icons"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -36,6 +49,41 @@ interface BanYouClassroom {
|
|||||||
isOwn?: boolean | null
|
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 }) => {
|
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
const UNGROUPED_KEY = "__ungrouped__"
|
const UNGROUPED_KEY = "__ungrouped__"
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -79,7 +127,19 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [textImportValue, setTextImportValue] = useState("")
|
const [textImportValue, setTextImportValue] = useState("")
|
||||||
const [banYouCookie, setBanYouCookie] = useState("")
|
const [banYouCookie, setBanYouCookie] = useState("")
|
||||||
const [banYouLoading, setBanYouLoading] = useState(false)
|
const [banYouLoading, setBanYouLoading] = useState(false)
|
||||||
|
const [banYouDetailLoading, setBanYouDetailLoading] = useState(false)
|
||||||
|
const [banYouImportLoading, setBanYouImportLoading] = useState(false)
|
||||||
const [banYouClassrooms, setBanYouClassrooms] = useState<BanYouClassroom[]>([])
|
const [banYouClassrooms, setBanYouClassrooms] = useState<BanYouClassroom[]>([])
|
||||||
|
const [banYouDetailVisible, setBanYouDetailVisible] = useState(false)
|
||||||
|
const [banYouSelectedClass, setBanYouSelectedClass] = useState<BanYouClassroom | null>(null)
|
||||||
|
const [banYouDetail, setBanYouDetail] = useState<BanYouClassroomDetail | null>(null)
|
||||||
|
const [banYouCheckedMedals, setBanYouCheckedMedals] = useState<string[]>([])
|
||||||
|
const [banYouCheckedStudents, setBanYouCheckedStudents] = useState<string[]>([])
|
||||||
|
const [banYouCheckedTeams, setBanYouCheckedTeams] = useState<string[]>([])
|
||||||
|
const [banYouTeamPlanOptions, setBanYouTeamPlanOptions] = useState<BanYouTeamPlanOption[]>([])
|
||||||
|
const [banYouSelectedTeamPlanId, setBanYouSelectedTeamPlanId] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
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()
|
||||||
@@ -672,19 +732,20 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
return extractUniqueNames(lines)
|
return extractUniqueNames(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
const importNames = async (names: string[]) => {
|
const importNames = async (names: string[], options?: { toast?: boolean }) => {
|
||||||
if (!(window as any).api) return false
|
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 })
|
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
||||||
if (!res?.success) {
|
if (!res?.success) {
|
||||||
messageApi.error(res?.message || t("students.importFailed"))
|
if (toast) messageApi.error(res?.message || t("students.importFailed"))
|
||||||
return false
|
return { success: false, inserted: 0, skipped: 0 }
|
||||||
}
|
}
|
||||||
const inserted = Number(res?.data?.inserted ?? 0)
|
const inserted = Number(res?.data?.inserted ?? 0)
|
||||||
const skipped = Number(res?.data?.skipped ?? 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()
|
fetchStudents()
|
||||||
emitDataUpdated("students")
|
emitDataUpdated("students")
|
||||||
return true
|
return { success: true, inserted, skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmXlsxImport = async () => {
|
const handleConfirmXlsxImport = async () => {
|
||||||
@@ -705,8 +766,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
setXlsxLoading(true)
|
setXlsxLoading(true)
|
||||||
try {
|
try {
|
||||||
const success = await importNames(names)
|
const result = await importNames(names)
|
||||||
if (!success) return
|
if (!result.success) return
|
||||||
setXlsxVisible(false)
|
setXlsxVisible(false)
|
||||||
setXlsxAoa([])
|
setXlsxAoa([])
|
||||||
setXlsxFileName("")
|
setXlsxFileName("")
|
||||||
@@ -729,8 +790,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
setTextImportLoading(true)
|
setTextImportLoading(true)
|
||||||
try {
|
try {
|
||||||
const success = await importNames(names)
|
const result = await importNames(names)
|
||||||
if (!success) return
|
if (!result.success) return
|
||||||
setTextImportValue("")
|
setTextImportValue("")
|
||||||
setTextImportVisible(false)
|
setTextImportVisible(false)
|
||||||
} finally {
|
} 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<string>()
|
||||||
|
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<string, string>()
|
||||||
|
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<string, number>()
|
||||||
|
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(
|
const banYouCreatedClasses = useMemo(
|
||||||
() => banYouClassrooms.filter((item) => item.isOwn !== false),
|
() => banYouClassrooms.filter((item) => item.isOwn !== false),
|
||||||
[banYouClassrooms]
|
[banYouClassrooms]
|
||||||
@@ -1322,11 +1562,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
{banYouCreatedClasses.map((item) => (
|
{banYouCreatedClasses.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`created-${item.classId}`}
|
key={`created-${item.classId}`}
|
||||||
|
onClick={() => handleOpenBanYouClassDetail(item)}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--ss-border-color)",
|
border: "1px solid var(--ss-border-color)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: "var(--ss-card-bg)",
|
backgroundColor: "var(--ss-card-bg)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1408,12 +1650,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
{banYouJoinedClasses.map((item) => (
|
{banYouJoinedClasses.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`joined-${item.classId}`}
|
key={`joined-${item.classId}`}
|
||||||
|
onClick={() => handleOpenBanYouClassDetail(item)}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--ss-border-color)",
|
border: "1px solid var(--ss-border-color)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: "var(--ss-card-bg)",
|
backgroundColor: "var(--ss-card-bg)",
|
||||||
padding: 14,
|
padding: 14,
|
||||||
color: "var(--ss-text-main)",
|
color: "var(--ss-text-main)",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 8 }}>
|
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 8 }}>
|
||||||
@@ -1443,6 +1687,161 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
banYouSelectedClass
|
||||||
|
? `${t("students.banyouClassDetail")} - ${banYouSelectedClass.classNickName}`
|
||||||
|
: t("students.banyouClassDetail")
|
||||||
|
}
|
||||||
|
open={banYouDetailVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
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 ? (
|
||||||
|
<div style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}>
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : !banYouDetail ? (
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)" }}>{t("students.banyouDetailEmpty")}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxHeight: "70vh" }}>
|
||||||
|
{banYouTeamPlanOptions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
||||||
|
{t("students.banyouTeamPlanSelector")}
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ minWidth: 320 }}
|
||||||
|
value={banYouSelectedTeamPlanId}
|
||||||
|
onChange={(v) => setBanYouSelectedTeamPlanId(Number(v))}
|
||||||
|
options={banYouTeamPlanOptions.map((item) => ({
|
||||||
|
value: item.teamPlanId,
|
||||||
|
label: item.name?.trim()
|
||||||
|
? `${item.name} (${item.teamPlanId})`
|
||||||
|
: String(item.teamPlanId),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button loading={banYouDetailLoading} onClick={handleReloadBanYouGroupByPlan}>
|
||||||
|
{t("students.banyouLoadTeamPlan")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
{banYouDetail.teamPlanSource ? (
|
||||||
|
<div style={{ marginTop: 6, color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||||
|
{t("students.banyouTeamPlanSource")}
|
||||||
|
{banYouDetail.teamPlanSource}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouReasonList")}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
maxHeight: 180,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={banYouCheckedMedals}
|
||||||
|
onChange={(vals) => setBanYouCheckedMedals(vals as string[])}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{banYouDetail.medals.map((item, idx) => {
|
||||||
|
const key = medalKey(item, idx)
|
||||||
|
const type = Number(item.medalType ?? item.type ?? 1)
|
||||||
|
const value = Math.abs(Number(item.value ?? 1)) || 1
|
||||||
|
const delta = type < 0 ? -value : value
|
||||||
|
return (
|
||||||
|
<Checkbox key={key} value={key}>
|
||||||
|
{item.name} ({delta > 0 ? `+${delta}` : delta})
|
||||||
|
</Checkbox>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouStudentList")}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={banYouCheckedStudents}
|
||||||
|
onChange={(vals) => setBanYouCheckedStudents(vals as string[])}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{banYouDetail.students.map((item) => (
|
||||||
|
<Checkbox key={item.studentId} value={item.studentId}>
|
||||||
|
{item.studentName}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouTeamList")}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={banYouCheckedTeams}
|
||||||
|
onChange={(vals) => setBanYouCheckedTeams(vals as string[])}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{banYouDetail.teams.length > 0 ? (
|
||||||
|
banYouDetail.teams.map((team) => (
|
||||||
|
<Checkbox key={team.teamId} value={team.teamId}>
|
||||||
|
{team.teamName} ({(team.students || []).length})
|
||||||
|
</Checkbox>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||||
|
{t("students.banyouNoTeams")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={xlsxInputRef}
|
ref={xlsxInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -497,6 +497,20 @@
|
|||||||
"banyouClassTeacher": "Teacher: ",
|
"banyouClassTeacher": "Teacher: ",
|
||||||
"banyouStudentCount": "Students: ",
|
"banyouStudentCount": "Students: ",
|
||||||
"banyouPraiseCount": "Praise: ",
|
"banyouPraiseCount": "Praise: ",
|
||||||
|
"banyouFetchDetailFailed": "Failed to fetch classroom details",
|
||||||
|
"banyouClassDetail": "Classroom Details",
|
||||||
|
"banyouDetailEmpty": "No classroom detail data",
|
||||||
|
"banyouReasonList": "Reason List",
|
||||||
|
"banyouStudentList": "Student List",
|
||||||
|
"banyouTeamList": "Group List",
|
||||||
|
"banyouNoTeams": "No group data (try filling teamPlanId and reopen)",
|
||||||
|
"banyouTeamPlanSelector": "Group Plan",
|
||||||
|
"banyouLoadTeamPlan": "Load This Plan",
|
||||||
|
"banyouTeamPlanSource": "Plan source API: ",
|
||||||
|
"banyouSelectTeamPlanFirst": "Please select a group plan first",
|
||||||
|
"banyouImportSelected": "Import Selected",
|
||||||
|
"banyouNothingSelected": "Please select at least one item to import",
|
||||||
|
"banyouImportSummary": "BanYou import done: Students +{{studentsInserted}}/{{studentsSkipped}} skipped; Reasons +{{reasonsInserted}}/{{reasonsSkipped}} skipped; Groups updated {{groupsUpdated}}/{{groupsSkipped}} skipped",
|
||||||
"editTagTitle": "Edit Tags - {{name}}",
|
"editTagTitle": "Edit Tags - {{name}}",
|
||||||
"editGroup": "Set Group",
|
"editGroup": "Set Group",
|
||||||
"editGroupTitle": "Set Group - {{name}}",
|
"editGroupTitle": "Set Group - {{name}}",
|
||||||
|
|||||||
@@ -497,6 +497,20 @@
|
|||||||
"banyouClassTeacher": "班主任:",
|
"banyouClassTeacher": "班主任:",
|
||||||
"banyouStudentCount": "学生:",
|
"banyouStudentCount": "学生:",
|
||||||
"banyouPraiseCount": "表扬:",
|
"banyouPraiseCount": "表扬:",
|
||||||
|
"banyouFetchDetailFailed": "获取班级详情失败",
|
||||||
|
"banyouClassDetail": "班级详情",
|
||||||
|
"banyouDetailEmpty": "暂无班级详情数据",
|
||||||
|
"banyouReasonList": "加分/扣分理由列表",
|
||||||
|
"banyouStudentList": "学生列表",
|
||||||
|
"banyouTeamList": "小组列表",
|
||||||
|
"banyouNoTeams": "暂无小组数据(可尝试填写 teamPlanId 后重新打开)",
|
||||||
|
"banyouTeamPlanSelector": "分组方案",
|
||||||
|
"banyouLoadTeamPlan": "加载该方案小组",
|
||||||
|
"banyouTeamPlanSource": "方案来源接口:",
|
||||||
|
"banyouSelectTeamPlanFirst": "请先选择分组方案",
|
||||||
|
"banyouImportSelected": "一键导入勾选项",
|
||||||
|
"banyouNothingSelected": "请至少勾选一项后再导入",
|
||||||
|
"banyouImportSummary": "班优导入完成:学生 新增{{studentsInserted}}/跳过{{studentsSkipped}};理由 新增{{reasonsInserted}}/跳过{{reasonsSkipped}};分组 更新{{groupsUpdated}}/跳过{{groupsSkipped}}",
|
||||||
"editTagTitle": "编辑标签 - {{name}}",
|
"editTagTitle": "编辑标签 - {{name}}",
|
||||||
"editGroup": "设置小组",
|
"editGroup": "设置小组",
|
||||||
"editGroupTitle": "设置小组 - {{name}}",
|
"editGroupTitle": "设置小组 - {{name}}",
|
||||||
|
|||||||
@@ -117,6 +117,47 @@ const api = {
|
|||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("student_fetch_banyou_classrooms", { params }),
|
}> => 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
|
// 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