第一次提交
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5594
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "attendance_app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "attendance_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2"
|
||||
calamine = "0.32.0"
|
||||
rust_xlsxwriter = "0.92.2"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
anyhow = "1.0.100"
|
||||
dirs = "6.0.0"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
75
src-tauri/src/data_processor.rs
Normal 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
|
||||
}
|
||||
137
src-tauri/src/excel_reader.rs
Normal 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)
|
||||
}
|
||||
112
src-tauri/src/excel_writer.rs
Normal 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, "次数", ¢er_format)?;
|
||||
worksheet.write_string_with_format(current_row, sub_col_idx + 1, "单价", ¢er_format)?;
|
||||
worksheet.write_string_with_format(current_row, sub_col_idx + 2, "费用合计", ¢er_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, ¢er_format)?;
|
||||
if let Some(year) = &student.student.year {
|
||||
worksheet.write_string_with_format(current_row, 1, year, ¢er_format)?;
|
||||
}
|
||||
if let Some(grade) = &student.student.grade {
|
||||
worksheet.write_string_with_format(current_row, 2, grade, ¢er_format)?;
|
||||
}
|
||||
if let Some(class_info) = &student.student.class {
|
||||
worksheet.write_string_with_format(current_row, 3, class_info, ¢er_format)?;
|
||||
}
|
||||
worksheet.write_string_with_format(current_row, 4, &student.student.name, ¢er_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, ¢er_format)?;
|
||||
worksheet.write_number_with_format(current_row, start_col + 1, record.price, ¢er_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(), ¢er_format)?;
|
||||
}
|
||||
}
|
||||
current_row += 1;
|
||||
}
|
||||
|
||||
current_row += 2; // Spacing between classes
|
||||
}
|
||||
|
||||
workbook.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
82
src-tauri/src/lib.rs
Normal 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
@@ -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()
|
||||
}
|
||||
33
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "attendance_app",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.attendance.app",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "attendance_app",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||