From 212bf11024c9dd56ad5e09eb23a6fd204bfeac2c Mon Sep 17 00:00:00 2001 From: Mingshu Chen Date: Sat, 27 Jun 2026 19:31:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20curriculum=20(danke/=E8=AF=BE?= =?UTF-8?q?=E8=AF=84)=20API=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CURRICULUM_BASE to api module for danke.fduhole.com - Add CourseGroup, Course, Review, Rank, CurriculumExtra models - Add API methods: list_course_groups, get_course_group, get_course, list_reviews, create_review - Add CLI commands: courses, course, reviews with search and formatted output --- src/api/mod.rs | 88 +++++++++++++++++++++++++ src/cli/mod.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 96 ++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index c802fe7..9490877 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,7 @@ use crate::models::*; const AUTH_BASE: &str = "https://auth.fduhole.com/api"; const FORUM_BASE: &str = "https://forum.fduhole.com/api"; +const CURRICULUM_BASE: &str = "https://danke.fduhole.com/api"; #[derive(Clone)] pub struct DantaClient { @@ -575,4 +576,91 @@ impl DantaClient { let resp = check(resp, "Get my floors").await?; Ok(resp.json().await?) } + + // ═══════════════════════════════════════ + // Curriculum (课评 / Danke) + // ═══════════════════════════════════════ + + pub async fn list_course_groups(&self) -> Result> { + let (k, v) = self.auth_header()?; + let resp = self + .http + .get(format!("{CURRICULUM_BASE}/courses")) + .header(k, v) + .send() + .await?; + let resp = check(resp, "List course groups").await?; + Ok(resp.json().await?) + } + + pub async fn get_course_group(&self, group_id: i64) -> Result { + let (k, v) = self.auth_header()?; + let resp = self + .http + .get(format!("{CURRICULUM_BASE}/group/{}", group_id)) + .header(k, v) + .send() + .await?; + let resp = check(resp, "Get course group").await?; + Ok(resp.json().await?) + } + + pub async fn get_course(&self, course_id: i64) -> Result { + let (k, v) = self.auth_header()?; + let resp = self + .http + .get(format!("{CURRICULUM_BASE}/courses/{}", course_id)) + .header(k, v) + .send() + .await?; + let resp = check(resp, "Get course").await?; + Ok(resp.json().await?) + } + + pub async fn list_reviews(&self, course_id: i64) -> Result> { + let (k, v) = self.auth_header()?; + let resp = self + .http + .get(format!( + "{CURRICULUM_BASE}/courses/{}/reviews", + course_id + )) + .header(k, v) + .send() + .await?; + let resp = check(resp, "List reviews").await?; + Ok(resp.json().await?) + } + + pub async fn create_review( + &self, + course_id: i64, + title: &str, + content: &str, + rank: &Rank, + ) -> Result { + let (k, v) = self.auth_header()?; + let body = serde_json::json!({ + "title": title, + "content": content, + "rank": { + "overall": rank.overall, + "content": rank.content, + "workload": rank.workload, + "assessment": rank.assessment, + } + }); + let resp = self + .http + .post(format!( + "{CURRICULUM_BASE}/courses/{}/reviews", + course_id + )) + .header(k, v) + .json(&body) + .send() + .await?; + let resp = check(resp, "Create review").await?; + Ok(resp.json().await?) + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2c3bd11..22c08c8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -180,6 +180,23 @@ pub enum Commands { #[arg(short, long, default_value = "127.0.0.1")] bind: String, }, + /// List course groups (课评) + Courses { + /// Group ID to view (omit to list all groups) + #[arg(short, long)] + group: Option, + /// Filter courses by name keyword (client-side) + #[arg(short, long)] + search: Option, + }, + /// View a specific course and its reviews + Course { + course_id: i64, + }, + /// List reviews for a course + Reviews { + course_id: i64, + }, } // Helper struct for JSON output of View command @@ -704,10 +721,152 @@ pub async fn run_cli(cmd: Commands, json: bool) -> Result<()> { } } Commands::Tui | Commands::Serve { .. } => unreachable!(), + Commands::Courses { group, search } => { + let client = get_client().await?; + if let Some(gid) = group { + let cg = client.get_course_group(gid).await?; + let courses = apply_search(&cg.courses, &search); + output_courses(&cg, &courses, json); + } else { + let groups = client.list_course_groups().await?; + if json { + json_out(&groups); + } else { + for g in &groups { + let count = g.courses.len(); + println!( + "[{}] {} ({}) — {} courses", + g.id, g.name, g.code, count + ); + } + } + } + } + Commands::Course { course_id } => { + let client = get_client().await?; + let course = client.get_course(course_id).await?; + let reviews = client.list_reviews(course_id).await?; + if json { + #[derive(Serialize)] + struct CourseOutput { + course: crate::models::Course, + reviews: Vec, + } + json_out(&CourseOutput { course, reviews }); + } else { + print_course(&course, &reviews); + } + } + Commands::Reviews { course_id } => { + let client = get_client().await?; + let reviews = client.list_reviews(course_id).await?; + if json { + json_out(&reviews); + } else { + print_reviews(&reviews); + } + } } Ok(()) } +fn apply_search<'a>(courses: &'a [crate::models::Course], keyword: &Option) -> Vec<&'a crate::models::Course> { + match keyword { + Some(kw) if !kw.is_empty() => courses + .iter() + .filter(|c| { + c.name.contains(kw.as_str()) + || c.code.contains(kw.as_str()) + || c.teachers.contains(kw.as_str()) + }) + .collect(), + _ => courses.iter().collect(), + } +} + +fn output_courses( + group: &crate::models::CourseGroup, + courses: &[&crate::models::Course], + json: bool, +) { + if json { + json_out(&courses); + } else { + println!("── {} [{}] ──", group.name, group.code); + if courses.is_empty() { + println!(" (no courses found)"); + } else { + for c in courses { + let stars = if !c.reviews.is_empty() { + let avg: f64 = c.reviews.iter().map(|r| r.rank.overall).sum::() + / c.reviews.len() as f64; + format!(" ★{:.1} ({} reviews)", avg, c.reviews.len()) + } else { + String::new() + }; + println!( + " [#{}] {} ({}) | {} credits | {} | {}{}", + c.id, + c.name, + c.code, + c.credit, + c.teachers, + c.semester_name(), + stars, + ); + } + } + } +} + +fn print_course(course: &crate::models::Course, reviews: &[crate::models::Review]) { + let avg_rank = if !reviews.is_empty() { + let n = reviews.len() as f64; + let overall: f64 = reviews.iter().map(|r| r.rank.overall).sum(); + let content: f64 = reviews.iter().map(|r| r.rank.content).sum(); + let workload: f64 = reviews.iter().map(|r| r.rank.workload).sum(); + let assessment: f64 = reviews.iter().map(|r| r.rank.assessment).sum(); + (overall / n, content / n, workload / n, assessment / n, n as i64) + } else { + (0.0, 0.0, 0.0, 0.0, 0) + }; + + println!("═══ {} [{}] ═══", course.name, course.code); + println!("Teachers: {}", course.teachers); + println!("Credits: {} | Hours: {} | Max students: {}", course.credit, course.week_hour, course.max_student); + println!("Department: {} | Campus: {}", course.department, course.campus); + println!("Semester: {}", course.semester_name()); + if avg_rank.4 > 0 { + println!(); + println!("── Average Ratings ({reviews}) ──", reviews = avg_rank.4); + println!(" Overall: ★{:.1}/5", avg_rank.0); + println!(" Content: ★{:.1}/5", avg_rank.1); + println!(" Workload: ★{:.1}/5 (higher = lighter)", avg_rank.2); + println!(" Assessment: ★{:.1}/5 (higher = fairer)", avg_rank.3); + } + if !reviews.is_empty() { + println!(); + print_reviews(reviews); + } else { + println!(); + println!("(no reviews yet)"); + } +} + +fn print_reviews(reviews: &[crate::models::Review]) { + for (i, r) in reviews.iter().enumerate() { + println!( + "── Review #{} ({}↑) [{}] ──", + i + 1, r.vote, r.time_created + ); + println!(" Title: {}", r.title); + println!(" Overall:★{:.1} Content:★{:.1} Workload:★{:.1} Assessment:★{:.1}", + r.rank.overall, r.rank.content, r.rank.workload, r.rank.assessment); + println!(" {}", r.content.replace('\n', "\n ")); + println!(); + } +} + async fn get_client() -> Result { let token = auth::load_token()?; let mut client = DantaClient::with_token(token); diff --git a/src/models/mod.rs b/src/models/mod.rs index ce3a048..906dc00 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -182,3 +182,99 @@ pub struct User { #[serde(default)] pub joined_time: String, } + +// ── Curriculum (课评 / Danke) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CourseGroup { + pub id: i64, + pub name: String, + pub code: String, + #[serde(default)] + pub department: String, + #[serde(default, rename = "campus_name")] + pub campus: String, + #[serde(default, rename = "course_list")] + pub courses: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Course { + pub id: i64, + #[serde(default)] + pub credit: f64, + pub name: String, + pub code: String, + #[serde(default, rename = "code_id")] + pub code_id: String, + #[serde(default)] + pub department: String, + #[serde(default, rename = "campus_name")] + pub campus: String, + #[serde(default)] + pub teachers: String, + #[serde(default, rename = "max_student")] + pub max_student: i64, + #[serde(default, rename = "week_hour")] + pub week_hour: i64, + #[serde(default)] + pub year: i64, + #[serde(default)] + pub semester: i64, + #[serde(default)] + pub reviews: Vec, +} + +impl Course { + pub fn semester_name(&self) -> String { + let term = if self.semester % 2 == 1 { "秋" } else { "春" }; + format!("{}-{} {}", self.year, self.year + 1, term) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Review { + pub id: i64, + #[serde(default, rename = "reviewer_id")] + pub reviewer_id: i64, + pub title: String, + pub content: String, + #[serde(default, rename = "time_created")] + pub time_created: String, + #[serde(default, rename = "time_updated")] + pub time_updated: String, + #[serde(default, rename = "is_me")] + pub is_me: bool, + #[serde(default)] + pub remark: i64, + #[serde(default)] + pub vote: i64, + pub rank: Rank, + #[serde(default)] + pub extra: CurriculumExtra, + #[serde(default)] + pub history: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CurriculumExtra { + #[serde(default)] + pub achievements: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Achievement { + pub name: String, + #[serde(default, rename = "obtainDate")] + pub obtain_date: String, + #[serde(default)] + pub domain: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rank { + pub overall: f64, + pub content: f64, + pub workload: f64, + pub assessment: f64, +}