Initial commit: 台銀即期賣出匯率換算工具
This commit is contained in:
3
bot_rates_rs/.cargo/config.toml
Normal file
3
bot_rates_rs/.cargo/config.toml
Normal 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
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
16
bot_rates_rs/Cargo.toml
Normal 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
411
bot_rates_rs/src/main.rs
Normal 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))),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user