mod api;
mod client;
pub mod commands;
mod display;
pub mod login;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "campuscard", about = "北大校园卡 CLI 客户端", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Login {
#[arg(short, long)]
password: bool,
#[arg(short, long)]
username: Option<String>,
#[arg(long)]
open: bool,
},
Status,
Logout,
Info,
Pay {
#[arg(short, long)]
output: Option<String>,
},
Recharge {
#[arg(short, long)]
amount: Option<f64>,
},
#[command(alias = "ls")]
Bills {
#[arg(short, long, default_value = "1")]
page: usize,
#[arg(short = 'n', long, default_value = "10")]
size: usize,
#[arg(short, long)]
month: Option<String>,
},
Stats {
#[arg(short, long)]
month: Option<String>,
},
Otp {
#[command(subcommand)]
action: OtpAction,
},
}
#[derive(Subcommand)]
pub enum OtpAction {
Bind {
#[arg(short, long)]
username: Option<String>,
#[arg(long, conflicts_with = "verify")]
send: bool,
#[arg(long, value_name = "CODE", conflicts_with = "send")]
verify: Option<String>,
},
Set {
secret: String,
#[arg(short, long)]
username: Option<String>,
},
Show,
Clear,
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.init();
}
pub async fn run() -> Result<()> {
init_tracing();
let cli = Cli::parse();
dispatch(cli.command).await
}
pub async fn run_from<I, T>(args: I) -> Result<()>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = Cli::try_parse_from(args)?;
dispatch(cli.command).await
}
pub async fn dispatch(command: Commands) -> Result<()> {
match command {
Commands::Login {
password,
username,
open,
} => {
if password {
login::login_with_password(username.as_deref()).await?;
} else {
let qr_mode = if open {
pkuinfo_common::qr::QrDisplayMode::Open
} else {
pkuinfo_common::qr::QrDisplayMode::Terminal
};
login::login_with_qrcode(qr_mode).await?;
}
}
Commands::Status => login::status()?,
Commands::Logout => login::logout()?,
Commands::Info => commands::cmd_info().await?,
Commands::Pay { output } => commands::cmd_pay(output.as_deref()).await?,
Commands::Recharge { amount } => commands::cmd_recharge(amount).await?,
Commands::Bills { page, size, month } => {
commands::cmd_bills(Some(page), Some(size), month.as_deref()).await?;
}
Commands::Stats { month } => commands::cmd_stats(month.as_deref()).await?,
Commands::Otp { action } => {
let store = pkuinfo_common::session::Store::new("campuscard")?;
handle_otp(action, store.config_dir()).await?;
}
}
Ok(())
}
async fn handle_otp(action: OtpAction, config_dir: &std::path::Path) -> anyhow::Result<()> {
use colored::Colorize;
match action {
OtpAction::Bind {
username,
send,
verify,
} => {
if send {
pkuinfo_common::otp::bind_otp_send_sms(config_dir, username.as_deref()).await?;
} else if let Some(code) = verify {
pkuinfo_common::otp::bind_otp_verify(config_dir, &code).await?;
} else {
pkuinfo_common::otp::bind_otp_interactive(config_dir, username.as_deref()).await?;
}
}
OtpAction::Set { secret, username } => {
let uid = username.unwrap_or_default();
pkuinfo_common::otp::set_otp_secret(config_dir, &secret, &uid)?;
}
OtpAction::Show => match pkuinfo_common::otp::get_current_otp(config_dir)? {
Some(code) => {
let config = pkuinfo_common::otp::load_otp_config(config_dir)?
.ok_or_else(|| anyhow::anyhow!("OTP 配置文件缺失,请先运行 `otp bind`"))?;
println!(
"{} {} ({})",
"OTP:".green().bold(),
code.bold(),
config.user_id
);
}
None => {
println!(
"{} 未配置 OTP。使用 `otp bind` 绑定或 `otp set <SECRET>` 手动设置",
"○".red()
);
}
},
OtpAction::Clear => {
pkuinfo_common::otp::clear_otp_config(config_dir)?;
println!("{} OTP 配置已清除", "✓".green());
}
}
Ok(())
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");