Initial commit: 台銀即期賣出匯率換算工具

This commit is contained in:
Timmy
2026-04-17 12:36:08 +08:00
commit 1091f049ba
16 changed files with 3812 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-ar"

2795
bot_rates_rs/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
bot_rates_rs/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "bot_rates"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = { version = "0.27", default-features = false, features = ["glow", "default_fonts"] }
egui = "0.27"
ureq = { version = "2.10", default-features = false, features = ["tls"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
panic = "abort"

411
bot_rates_rs/src/main.rs Normal file
View File

@@ -0,0 +1,411 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::collections::HashMap;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use eframe::egui;
const URL: &str = "https://rate.bot.com.tw/xrt/flcsv/0/day";
const TARGETS: &[(&str, &str)] = &[("USD", "美元"), ("JPY", "日幣"), ("EUR", "歐元")];
#[derive(Clone, Debug)]
struct Rates {
items: Vec<(String, String, f64)>, // code, name, rate
fetched_at: String,
}
enum Msg {
Ok(Rates),
Err(String),
}
fn fetch_rates() -> Result<Rates, String> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(10))
.user_agent("Mozilla/5.0 bot_rates")
.build();
let body = agent
.get(URL)
.call()
.map_err(|e| format!("{e}"))?
.into_string()
.map_err(|e| format!("{e}"))?;
let body = body.trim_start_matches('\u{feff}');
let mut items = Vec::new();
for (idx, line) in body.lines().enumerate() {
if idx == 0 {
continue;
}
let cols: Vec<&str> = line.split(',').collect();
if cols.len() < 14 {
continue;
}
let code = cols[0].trim();
if let Some((_, name)) = TARGETS.iter().find(|(c, _)| *c == code) {
if let Ok(v) = cols[13].trim().parse::<f64>() {
items.push((code.to_string(), (*name).to_string(), v));
}
}
}
items.sort_by_key(|it| {
TARGETS
.iter()
.position(|(c, _)| *c == it.0)
.unwrap_or(usize::MAX)
});
if items.is_empty() {
return Err("解析結果為空".into());
}
let fetched_at = now_string();
Ok(Rates { items, fetched_at })
}
fn now_string() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64
+ 8 * 3600;
let days = secs / 86_400;
let sod = secs % 86_400;
let (h, m, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
let (y, mo, d) = civil_from_days(days);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02}")
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m as u32, d as u32)
}
fn setup_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let candidates: &[&str] = &[
r"C:\Windows\Fonts\msjh.ttc",
r"C:\Windows\Fonts\msjhl.ttc",
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
];
for path in candidates {
if let Ok(bytes) = std::fs::read(path) {
fonts
.font_data
.insert("cjk".to_owned(), egui::FontData::from_owned(bytes));
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "cjk".to_owned());
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("cjk".to_owned());
break;
}
}
ctx.set_fonts(fonts);
}
fn parse_amount(s: &str) -> Option<f64> {
let cleaned: String = s.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
if cleaned.is_empty() {
return None;
}
cleaned.parse::<f64>().ok()
}
fn format_twd(v: f64) -> String {
let rounded = (v * 100.0).round() / 100.0;
let sign = if rounded < 0.0 { "-" } else { "" };
let abs = rounded.abs();
let int_part = abs.trunc() as u64;
let frac = ((abs - int_part as f64) * 100.0).round() as u64;
let int_str = int_part.to_string();
let mut with_sep = String::new();
for (i, ch) in int_str.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
with_sep.push(',');
}
with_sep.push(ch);
}
let int_sep: String = with_sep.chars().rev().collect();
format!("{sign}{int_sep}.{frac:02}")
}
fn format_twd_raw(v: f64) -> String {
let rounded = (v * 100.0).round() / 100.0;
format!("{rounded:.2}")
}
struct App {
rx: Option<mpsc::Receiver<Msg>>,
rates: Option<Rates>,
error: Option<String>,
loading: bool,
fonts_ready: bool,
amounts: HashMap<String, String>,
copied: Option<(String, Instant)>,
}
impl App {
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let mut amounts = HashMap::new();
for (code, _) in TARGETS {
amounts.insert((*code).to_string(), "100".to_string());
}
let mut app = Self {
rx: None,
rates: None,
error: None,
loading: false,
fonts_ready: false,
amounts,
copied: None,
};
app.start_fetch();
app
}
fn start_fetch(&mut self) {
if self.loading {
return;
}
self.loading = true;
self.error = None;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let msg = match fetch_rates() {
Ok(r) => Msg::Ok(r),
Err(e) => Msg::Err(e),
};
let _ = tx.send(msg);
});
self.rx = Some(rx);
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.fonts_ready {
setup_fonts(ctx);
self.fonts_ready = true;
}
if let Some(rx) = &self.rx {
if let Ok(msg) = rx.try_recv() {
match msg {
Msg::Ok(r) => {
self.rates = Some(r);
self.error = None;
}
Msg::Err(e) => self.error = Some(e),
}
self.loading = false;
self.rx = None;
} else {
ctx.request_repaint_after(Duration::from_millis(100));
}
}
let bg = egui::Color32::from_rgb(245, 246, 250);
let card = egui::Color32::WHITE;
let text_main = egui::Color32::from_rgb(31, 41, 55);
let text_sub = egui::Color32::from_rgb(107, 114, 128);
let rate_color = egui::Color32::from_rgb(15, 118, 110);
let twd_color = egui::Color32::from_rgb(180, 83, 9);
let err_color = egui::Color32::from_rgb(185, 28, 28);
let frame = egui::Frame::none().fill(bg).inner_margin(egui::Margin::same(18.0));
egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
ui.label(
egui::RichText::new("台灣銀行 即期賣出")
.size(20.0)
.strong()
.color(text_main),
);
ui.label(
egui::RichText::new("左欄輸入外幣金額,右欄顯示台幣換算")
.size(11.0)
.color(text_sub),
);
ui.add_space(12.0);
let items: Vec<(String, String, f64)> = self
.rates
.as_ref()
.map(|r| r.items.clone())
.unwrap_or_else(|| {
TARGETS
.iter()
.map(|(c, n)| ((*c).to_string(), (*n).to_string(), f64::NAN))
.collect()
});
egui::Frame::none()
.fill(card)
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(229, 231, 235)))
.rounding(egui::Rounding::same(6.0))
.inner_margin(egui::Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
for (i, (code, name, rate)) in items.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
}
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(110.0);
ui.label(
egui::RichText::new(format!("{name} {code}"))
.size(14.0)
.strong()
.color(text_main),
);
let rate_text = if rate.is_nan() {
"--".to_string()
} else {
format!("{rate:.4}")
};
ui.label(
egui::RichText::new(rate_text)
.size(12.0)
.color(rate_color)
.monospace(),
);
});
ui.add_space(4.0);
let input = self
.amounts
.entry(code.clone())
.or_insert_with(|| "0".to_string());
ui.add_sized(
[120.0, 28.0],
egui::TextEdit::singleline(input)
.hint_text("金額")
.font(egui::TextStyle::Monospace)
.horizontal_align(egui::Align::Max),
);
ui.label(
egui::RichText::new("").size(14.0).color(text_sub),
);
let twd = parse_amount(input)
.filter(|_| !rate.is_nan())
.map(|a| a * rate);
let twd_text = match twd {
Some(v) => format!("NT$ {}", format_twd(v)),
None => "NT$ --".to_string(),
};
let just_copied = self
.copied
.as_ref()
.is_some_and(|(c, t)| c == code && t.elapsed() < Duration::from_millis(1200));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let btn_text = if just_copied { "已複製" } else { "複製" };
let btn = egui::Button::new(
egui::RichText::new(btn_text).size(11.0),
)
.min_size(egui::vec2(48.0, 24.0));
if ui.add_enabled(twd.is_some(), btn).clicked() {
if let Some(v) = twd {
let raw = format_twd_raw(v);
ui.output_mut(|o| o.copied_text = raw);
self.copied = Some((code.clone(), Instant::now()));
}
}
ui.add_space(4.0);
ui.add(
egui::Label::new(
egui::RichText::new(twd_text)
.size(15.0)
.strong()
.color(twd_color)
.monospace(),
)
.selectable(true),
);
},
);
if just_copied {
ctx.request_repaint_after(Duration::from_millis(200));
}
});
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if let Some(err) = &self.error {
ui.label(
egui::RichText::new(format!("載入失敗:{err}"))
.size(11.0)
.color(err_color),
);
} else if self.loading {
ui.label(egui::RichText::new("載入中…").size(11.0).color(text_sub));
} else if let Some(r) = &self.rates {
ui.label(
egui::RichText::new(format!("更新時間:{}", r.fetched_at))
.size(11.0)
.color(text_sub),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let btn = egui::Button::new(
egui::RichText::new("重新整理").size(13.0).strong(),
);
if ui.add_enabled(!self.loading, btn).clicked() {
self.start_fetch();
}
});
});
});
}
}
fn main() -> Result<(), eframe::Error> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([620.0, 360.0])
.with_min_inner_size([620.0, 360.0])
.with_resizable(false)
.with_title("台灣銀行 即期賣出匯率"),
..Default::default()
};
eframe::run_native(
"台灣銀行 即期賣出匯率",
options,
Box::new(|cc| Box::new(App::new(cc))),
)
}