第一次提交

This commit is contained in:
2025-12-02 17:38:59 +08:00
parent 56f87ecd25
commit e09f1f38e6
40 changed files with 6647 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
use crate::excel_reader::{ClassData, Student};
use chrono::Datelike;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonthlyRecord {
pub month: u32,
pub month_name: String, // e.g., "9月"
pub count: i32,
pub price: f64,
pub total: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StudentReport {
pub student: Student,
pub monthly_records: Vec<MonthlyRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassReport {
pub class_name: String,
pub students: Vec<StudentReport>,
}
pub fn process_data(classes: Vec<ClassData>) -> Vec<ClassReport> {
let mut reports = Vec::new();
for class_data in classes {
let price = if class_data.class_name.contains("思维") {
30.0
} else {
24.0
};
let mut student_reports = Vec::new();
for record in class_data.records {
let mut month_counts: HashMap<u32, i32> = HashMap::new();
for (date, _) in record.attendance {
let month = date.month();
*month_counts.entry(month).or_insert(0) += 1;
}
let mut monthly_records = Vec::new();
let mut months: Vec<u32> = month_counts.keys().cloned().collect();
months.sort();
for month in months {
let count = month_counts[&month];
monthly_records.push(MonthlyRecord {
month,
month_name: format!("{}月", month),
count,
price,
total: count as f64 * price,
});
}
student_reports.push(StudentReport {
student: record.student,
monthly_records,
});
}
reports.push(ClassReport {
class_name: class_data.class_name,
students: student_reports,
});
}
reports
}

View File

@@ -0,0 +1,137 @@
use anyhow::{Context, Result};
use calamine::{open_workbook, Data, DataType, Reader, Xlsx};
use chrono::{Duration, NaiveDate};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Student {
pub id: Option<String>,
pub year: Option<String>,
pub grade: Option<String>,
pub class: Option<String>,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttendanceRecord {
pub student: Student,
// Date and attendance count (1.0 for present)
pub attendance: Vec<(NaiveDate, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassData {
pub class_name: String,
pub records: Vec<AttendanceRecord>,
}
pub fn read_attendance_file<P: AsRef<Path>>(path: P) -> Result<Vec<ClassData>> {
let mut workbook: Xlsx<_> = open_workbook(path).context("Failed to open Excel file")?;
let mut classes = Vec::new();
for sheet_name in workbook.sheet_names().to_owned() {
if let Ok(range) = workbook.worksheet_range(&sheet_name) {
let mut records = Vec::new();
let mut dates = Vec::new();
let mut date_col_indices = Vec::new();
// Find header row (row 2, index 1)
if range.height() < 2 {
continue;
}
// Parse headers to find date columns
// Assuming row 2 (index 1) contains headers and dates
let header_row = range.rows().nth(1).unwrap();
for (col_idx, cell) in header_row.iter().enumerate() {
// Dates in Excel are often floats or ints (serial dates)
// Check if the cell value looks like a date (e.g., > 40000)
if let Some(float_val) = cell.as_f64() {
if float_val > 40000.0 && float_val < 50000.0 {
// Convert serial date to NaiveDate
// Excel base date is usually 1899-12-30
let base_date = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
let days = float_val as i64;
if let Some(date) = base_date.checked_add_signed(Duration::days(days)) {
dates.push(date);
date_col_indices.push(col_idx);
}
}
}
}
if dates.is_empty() {
// Skip sheets without dates
continue;
}
// Iterate over data rows (starting from row 4, index 3)
// Row 1: Title, Row 2: Headers, Row 3: Weekdays, Row 4: Data
for row in range.rows().skip(3) {
// Check if row has a name (Column E, index 4)
// If name is empty, skip
let name_cell = &row[4];
let name = match name_cell {
Data::String(s) => s.trim().to_string(),
_ => continue, // Skip if no name
};
if name.is_empty() || name == "姓名" {
continue;
}
let id = row[0].to_string();
let year = row[1].to_string();
let grade = row[2].to_string();
let class_info = row[3].to_string();
let student = Student {
id: if id.is_empty() { None } else { Some(id) },
year: if year.is_empty() { None } else { Some(year) },
grade: if grade.is_empty() { None } else { Some(grade) },
class: if class_info.is_empty() { None } else { Some(class_info) },
name,
};
let mut attendance_entries = Vec::new();
for (i, &col_idx) in date_col_indices.iter().enumerate() {
if col_idx < row.len() {
let cell = &row[col_idx];
// Check for "1" or numeric 1
let is_present = match cell {
Data::Int(v) => *v == 1,
Data::Float(v) => (*v - 1.0).abs() < 1e-6,
Data::String(s) => s.trim() == "1",
_ => false,
};
if is_present {
if i < dates.len() {
attendance_entries.push((dates[i], 1.0));
}
}
}
}
if !attendance_entries.is_empty() {
records.push(AttendanceRecord {
student,
attendance: attendance_entries,
});
}
}
if !records.is_empty() {
classes.push(ClassData {
class_name: sheet_name.clone(),
records,
});
}
}
}
Ok(classes)
}

View File

@@ -0,0 +1,112 @@
use crate::data_processor::ClassReport;
use anyhow::Result;
use rust_xlsxwriter::{Format, Workbook};
use std::path::Path;
pub fn write_report<P: AsRef<Path>>(path: P, reports: Vec<ClassReport>) -> Result<()> {
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
// Formats
let header_format = Format::new().set_bold().set_align(rust_xlsxwriter::FormatAlign::Center);
let center_format = Format::new().set_align(rust_xlsxwriter::FormatAlign::Center);
let mut current_row = 0;
for report in reports {
// Write Class Name
worksheet.write_string(current_row, 0, &report.class_name)?;
current_row += 1;
// Write Headers
let headers = ["序号", "入学年份", "年级", "班级", "姓名"];
for (i, header) in headers.iter().enumerate() {
worksheet.write_string_with_format(current_row, i as u16, *header, &header_format)?;
}
// Determine all unique months across all students in this class to create columns
// Actually, the requirement implies we list months for each student?
// Looking at res.xlsx:
// Row 148: ..., 9月陈南岚老师, None, None, 10月陈南岚老师, ...
// Row 149: ..., 次数, 单价, 费用合计, 次数, 单价, 费用合计
// It seems the columns are dynamic based on months.
// We need to find all months present in this class and create columns for them.
// Collect all months
let mut all_months = std::collections::BTreeSet::new();
for student in &report.students {
for record in &student.monthly_records {
all_months.insert(record.month);
}
}
// Map month to start column index
let mut month_col_map = std::collections::HashMap::new();
let mut col_idx = 5; // Start after "姓名"
for month in &all_months {
// Write Month Header (merged 3 cells)
// We don't know the teacher name, so just use "X月"
let month_str = format!("{}", month);
worksheet.merge_range(current_row, col_idx, current_row, col_idx + 2, &month_str, &header_format)?;
month_col_map.insert(*month, col_idx);
col_idx += 3;
}
current_row += 1;
// Write Sub-headers (次数, 单价, 费用合计)
for _ in &all_months {
// We need to know the column index for this month
// But we are iterating linearly.
}
// Actually, let's iterate columns again
let mut sub_col_idx = 5;
for _ in &all_months {
worksheet.write_string_with_format(current_row, sub_col_idx, "次数", &center_format)?;
worksheet.write_string_with_format(current_row, sub_col_idx + 1, "单价", &center_format)?;
worksheet.write_string_with_format(current_row, sub_col_idx + 2, "费用合计", &center_format)?;
sub_col_idx += 3;
}
current_row += 1;
// Write Student Data
for (i, student) in report.students.iter().enumerate() {
worksheet.write_number_with_format(current_row, 0, (i + 1) as f64, &center_format)?;
if let Some(year) = &student.student.year {
worksheet.write_string_with_format(current_row, 1, year, &center_format)?;
}
if let Some(grade) = &student.student.grade {
worksheet.write_string_with_format(current_row, 2, grade, &center_format)?;
}
if let Some(class_info) = &student.student.class {
worksheet.write_string_with_format(current_row, 3, class_info, &center_format)?;
}
worksheet.write_string_with_format(current_row, 4, &student.student.name, &center_format)?;
for record in &student.monthly_records {
if let Some(&start_col) = month_col_map.get(&record.month) {
worksheet.write_number_with_format(current_row, start_col, record.count as f64, &center_format)?;
worksheet.write_number_with_format(current_row, start_col + 1, record.price, &center_format)?;
// Formula: =PRODUCT(次数:单价)
// Column indices are 0-based.
// rust_xlsxwriter uses (row, col)
let count_cell = rust_xlsxwriter::utility::row_col_to_cell(current_row, start_col);
let price_cell = rust_xlsxwriter::utility::row_col_to_cell(current_row, start_col + 1);
let formula = format!("=PRODUCT({}:{})", count_cell, price_cell);
worksheet.write_formula_with_format(current_row, start_col + 2, formula.as_str(), &center_format)?;
}
}
current_row += 1;
}
current_row += 2; // Spacing between classes
}
workbook.save(path)?;
Ok(())
}

82
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,82 @@
mod data_processor;
mod excel_reader;
mod excel_writer;
use std::path::PathBuf;
#[tauri::command]
async fn process_attendance(input_path: String) -> Result<String, String> {
let input_path = PathBuf::from(input_path);
// 1. Read
let classes = excel_reader::read_attendance_file(&input_path)
.map_err(|e| format!("Failed to read Excel: {}", e))?;
// 2. Process
let reports = data_processor::process_data(classes);
// 3. Determine output path
let output_name = "res.xlsx";
let mut output_path = std::env::current_dir()
.map_err(|e| format!("Failed to get current dir: {}", e))?
.join(output_name);
// Logic: If app (executable) is on desktop, save to desktop.
// Note: In dev mode, this might be target/debug/...
if let Some(desktop_dir) = dirs::desktop_dir() {
if let Ok(exe_path) = std::env::current_exe() {
if exe_path.starts_with(&desktop_dir) {
output_path = desktop_dir.join(output_name);
}
}
}
// If running in bundle on macOS, current_exe is inside .app/Contents/MacOS
// We might want to go up 3 levels to get the .app folder's parent.
// But for now, let's stick to the simple logic.
// If the user launches a shortcut on Desktop, the CWD might be Desktop?
// If so, current_dir() handles it.
// 4. Write
excel_writer::write_report(&output_path, reports)
.map_err(|e| format!("Failed to write Excel: {}", e))?;
Ok(format!("Successfully saved to {:?}", output_path))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_process_logic() {
// Assuming we are in src-tauri/
let input = PathBuf::from("../req.xlsx");
if !input.exists() {
println!("Skipping test, input file not found at {:?}", input);
return;
}
let classes = excel_reader::read_attendance_file(&input).expect("Failed to read");
let reports = data_processor::process_data(classes);
assert!(!reports.is_empty());
// Write to a test output
let output = PathBuf::from("../test_output.xlsx");
excel_writer::write_report(&output, reports).expect("Failed to write");
assert!(output.exists());
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![process_attendance])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
attendance_app_lib::run()
}