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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
.DS_Store

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>BotRates</string>
<key>CFBundleDisplayName</key>
<string>台銀即期賣出</string>
<key>CFBundleIdentifier</key>
<string>tw.local.botrates</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>BotRates</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

Binary file not shown.

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 專案性質
抓取台灣銀行 CSV 匯率並即時換算成台幣的跨平台 GUI 小工具。目標是「使用者雙擊即跑、零 runtime 依賴」,所以主程式是 Rust`bot_rates_rs/src/main.rs`);根目錄下的 `bot_rates.py` / `bot_rates_gui.py` / `build.bat` 是已被取代的早期 Python 版本,修改時請以 Rust 版為準。
## 常用指令
所有 cargo 指令都要在 `bot_rates_rs/` 下執行,並帶 `RUST_MIN_STACK=16777216`(少了這個 LLVM 在 release 優化 `eframe` 依賴時會堆疊溢位 SIGSEGV
```bash
cd bot_rates_rs
# macOS 版 release build
RUST_MIN_STACK=16777216 cargo build --release
# 輸出target/release/bot_rates
# Windows 版(從 macOS 交叉編譯,需先 brew install mingw-w64 並 rustup target add x86_64-pc-windows-gnu
RUST_MIN_STACK=16777216 cargo build --release --target x86_64-pc-windows-gnu
# 輸出target/x86_64-pc-windows-gnu/release/bot_rates.exe
# 快速 debug 跑一次(不用設 RUST_MIN_STACK
cargo run
```
部署(複製編譯產物到專案根目錄的散佈位置):
```bash
# macOS塞進 .app bundle 並清 quarantine
cp target/release/bot_rates ../BotRates.app/Contents/MacOS/BotRates
xattr -cr ../BotRates.app
# Windows複製 exe 到根目錄並重新壓 zip
cp target/x86_64-pc-windows-gnu/release/bot_rates.exe ../bot_rates.exe
cd .. && zip -9 bot_rates.zip bot_rates.exe
```
專案目前沒有測試、沒有 lint 設定、沒有 CI。
## 架構要點
單一檔案 Rust app~410 行 `src/main.rs`),關鍵分層:
- **資料抓取**`fetch_rates()``ureq` + `rustls`(刻意避開 `reqwest` / `native-tls`,因為交叉編譯會牽扯 OpenSSL / SChannel`https://rate.bot.com.tw/xrt/flcsv/0/day`。**CSV 解析用位置索引而非欄名**,因為台銀 CSV 有重複欄名(買入/賣出區段都叫「匯率/現金/即期」),`csv::DictReader` 式的解析會只保留最後一個。目前讀 `cols[13]` 即期賣出;買入在 `cols[3]`,現金買入 `cols[2]`、現金賣出 `cols[12]`
- **UI**`eframe` / `egui` 0.27 immediate mode。`App::update` 每幀重繪;抓匯率走背景 thread + `mpsc::Receiver``update``try_recv` 無阻塞輪詢,沒資料時 `request_repaint_after(100ms)` 自我喚醒。
- **輸入狀態**`App.amounts: HashMap<String, String>`,存字串不是 `f64`,這樣才能保留「`100.`」「空字串」「`1,000`」這類輸入中間態。`parse_amount` 才做清洗與 parse。
- **字型**egui 預設字型沒中文,會變豆腐。`setup_fonts` 在執行期讀 OS 字型Windows `msjh.ttc` / macOS `PingFang.ttc`,有 fallback 鏈),**刻意不 bundle 字型**以免 binary 胖 515 MB。如果在沒有這些字型的環境跑就會回到豆腐。
- **時間**`now_string` / `civil_from_days` 自己實作 UTC→台北時間換算與 Gregorian 日期計算,避免引入 `chrono` / `time` 把 binary 撐大。
- **複製**:「複製」按鈕用 `ui.output_mut(|o| o.copied_text = raw)` 寫純數字(`3162.00`,不含 `NT$` / 逗號)到剪貼簿,設計是為了貼到 Excel 會被當數字。TWD label 另外有 `.selectable(true)` 讓使用者自行選取。
## 擴充時的常見改點
| 目標 | 位置 |
|------|------|
| 新增幣別 | `TARGETS` 常數,加一行 `(代碼, 中文名)` 即可,其他地方會自動處理 |
| 改成即期買入/現金匯率 | `fetch_rates` 裡的 `cols[13]` 換成對應 index見上方資料抓取段 |
| 視窗尺寸/不可 resize | `main()``ViewportBuilder` |
| 預設輸入金額 | `App::new` 裡的 `"100".to_string()` |
| 配色 | `update` 開頭的 `bg` / `card` / `rate_color` / `twd_color` 等常數 |
## Release profile 的硬性約束
`Cargo.toml``[profile.release]``opt-level = 3, lto = "thin", codegen-units = 1, strip = true, panic = "abort"`。歷史上試過 `opt-level = "z"` + `lto = true` 會觸發 LLVM DeadArgumentElimination 的 SIGSEGV所以不要回頭改成更激進的設定。`RUST_MIN_STACK=16777216` 同樣是必備而非可選。
## macOS `.app` bundle
`BotRates.app/Contents/{Info.plist, MacOS/BotRates}` 是手動組的;沒有 cargo-bundle 或腳本自動化。改版本/顯示名稱要直接編輯 `Info.plist`。首次散佈給使用者前跑 `xattr -cr BotRates.app` 避免 Gatekeeper 擋下。

91
QUICKSTART.md Normal file
View File

@@ -0,0 +1,91 @@
# 快速上手
## 給終端使用者(只想用,不想裝東西)
### Windows
1. 拿到 `bot_rates.zip`
2. 對 zip 按右鍵 → 「解壓縮全部」
3. 雙擊 `bot_rates.exe`
完成。不用裝 Python不用裝任何 runtime單一檔案執行。
### macOS
1. 拿到 `BotRates.app`(整個資料夾)
2. 雙擊打開
若跳出「無法驗證開發者」:
```bash
xattr -cr /path/to/BotRates.app
```
或在「系統設定 → 隱私權與安全性」按「仍要打開」。
## 怎麼用
| 欄位 | 說明 |
|------|------|
| 左側 | 幣別、代碼、即期賣出匯率(例如 `美元 USD 31.6200` |
| 中間 | 輸入外幣金額(預設 100可自由修改 |
| 右側 | 台幣換算結果(例如 `NT$ 3,162.00` |
| 複製 | 按下複製純數字(`3162.00`)到剪貼簿,可直接貼 Excel |
| 重新整理 | 右下角按鈕,重新抓最新匯率 |
---
## 給開發者(想改程式或重新編譯)
### 先決條件(一次性)
```bash
# Rust 本體(已有可略)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 交叉編譯到 Windows 用的 linker
brew install mingw-w64
rustup target add x86_64-pc-windows-gnu
```
### 編譯
```bash
cd bot_rates_rs
# macOS 版
RUST_MIN_STACK=16777216 cargo build --release
# Windows 版(在 macOS 交叉編譯)
RUST_MIN_STACK=16777216 cargo build --release --target x86_64-pc-windows-gnu
```
### 部署
```bash
# mac 版複製進 .app bundle
cp target/release/bot_rates ../BotRates.app/Contents/MacOS/BotRates
xattr -cr ../BotRates.app
# Windows 版複製到專案根目錄並壓 zip
cp target/x86_64-pc-windows-gnu/release/bot_rates.exe ../bot_rates.exe
cd .. && zip -9 bot_rates.zip bot_rates.exe
```
### 常見修改點
| 想改什麼 | 改哪裡(`src/main.rs` |
|----------|------------------------|
| 新增幣別 | `TARGETS` 常數,加一行 `(代碼, 中文名)` |
| 換成即期買入 | `fetch_rates` 裡把 `cols[13]``cols[3]` |
| 視窗大小 | `main` 裡的 `with_inner_size` |
| 預設輸入金額 | `App::new` 裡的 `"100".to_string()` |
| 配色 | `update` 開頭的 `bg` / `card` / `rate_color` 等常數 |
### 欄位對照(台銀 CSV
| index | 欄位 |
|-------|------|
| 0 | 幣別 |
| 3 | 本行即期買入 |
| 13 | 本行即期賣出 ← 目前使用 |

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# 台銀即期賣出匯率換算工具
一個跨平台的小工具,抓取台灣銀行官方 USD / JPY / EUR 即期賣出匯率,並提供即時台幣換算功能。
## 特色
- **即時匯率**:從台銀官網 CSV`rate.bot.com.tw/xrt/flcsv/0/day`)抓取,免 API key
- **雙向換算**:每個幣別獨立輸入框,輸入外幣金額立即換算出台幣
- **一鍵複製**:按「複製」即把純數字(如 `3162.00`)寫入剪貼簿,可直接貼到 Excel
- **零依賴**:單一執行檔,使用者不需要安裝 Python、Rust 或任何 runtime
- **跨平台**Windows 與 macOS 共用同一份 Rust 原始碼
## 檔案結構
```
02/
├── bot_rates.exe # Windows 執行檔5.4 MB
├── bot_rates.zip # Windows 執行檔的 zip 壓縮2.7 MB方便傳送
├── BotRates.app/ # macOS 應用程式 bundle
│ └── Contents/
│ ├── Info.plist
│ └── MacOS/BotRates # macOS 執行檔5.2 MB
├── bot_rates_rs/ # Rust 原始碼
│ ├── Cargo.toml
│ ├── .cargo/config.toml # 交叉編譯 linker 設定
│ └── src/main.rs
├── bot_rates.py # 早期 Python CLI 版(僅列印匯率)
├── bot_rates_gui.py # 早期 Python tkinter GUI 版(已被 Rust 版取代)
├── build.bat # Python → exe 打包腳本(已不需要)
├── README.md
├── QUICKSTART.md
└── SUMMARY.md
```
## 使用方式
### 一般使用者
**Windows**:解壓 `bot_rates.zip`,雙擊 `bot_rates.exe` 即可執行。
**macOS**:雙擊 `BotRates.app`。首次開啟若跳出「無法驗證開發者」提示,請在「系統設定 → 隱私權與安全性」按「仍要打開」,或執行 `xattr -cr BotRates.app`
啟動後:
1. 每一列左側顯示幣別和即期賣出匯率
2. 中間輸入外幣金額(預設 100
3. 右側自動顯示台幣換算結果
4. 按「複製」按鈕即可把換算結果貼到其他軟體
5. 左下角顯示最後更新時間,右下角「重新整理」可重新抓匯率
### 開發者(重新編譯)
需要 Rust 工具鏈與 mingw-w64僅 Windows 交叉編譯需要):
```bash
# macOS 版
cd bot_rates_rs
RUST_MIN_STACK=16777216 cargo build --release
# 輸出target/release/bot_rates
# Windows 版(從 macOS 交叉編譯)
brew install mingw-w64
rustup target add x86_64-pc-windows-gnu
RUST_MIN_STACK=16777216 cargo build --release --target x86_64-pc-windows-gnu
# 輸出target/x86_64-pc-windows-gnu/release/bot_rates.exe
```
> `RUST_MIN_STACK=16777216` 是為了避免 LLVM 在優化階段堆疊溢位(`eframe` 依賴龐大optimize pipeline 會大量遞迴)。
## 技術棧
| 用途 | 函式庫 |
|------|--------|
| GUI framework | `eframe` / `egui` 0.27immediate mode無系統依賴 |
| HTTPS 請求 | `ureq` + `rustls`(純 Rust TLS不需要 OpenSSL 或 SChannel |
| 中文字型 | 執行期載入系統字型Windows 的 `msjh.ttc`、macOS 的 `PingFang.ttc` |
## 資料來源
- 台灣銀行牌告匯率 CSV<https://rate.bot.com.tw/xrt/flcsv/0/day>
- 欄位索引:`即期賣出` 位於第 14 欄index 13

126
SUMMARY.md Normal file
View File

@@ -0,0 +1,126 @@
# 開發過程摘要
記錄這個專案的演進脈絡、遇到的問題、以及為什麼做出這些選擇。
## 需求演進
| 階段 | 需求 | 產出 |
|------|------|------|
| 1 | 抓台銀 USD / JPY / EUR 即期賣出 | Python CLI (`bot_rates.py`) |
| 2 | 要給別人用、Windows、GUI、雙擊即跑、不要裝東西 | Python tkinter + PyInstaller 方案 |
| 3 | 太麻煩,改用 Rust使用者無 Windows 機器 | Rust + `eframe` + 交叉編譯 |
| 4 | 也給我一份 macOS 能跑的 | `BotRates.app` bundle |
| 5 | 壓縮 exe 方便傳送 | `bot_rates.zip`5.4 MB → 2.7 MB |
| 6 | 加上金額輸入 / 即時換算 | 三欄布局:幣別 / 輸入 / TWD |
| 7 | 換算結果要能直接複製 | 「複製」按鈕 + 可選取文字 |
## 階段 1Python CLI
直接用 `urllib` 抓台銀 CSV起初用 `csv.DictReader`但踩到一個坑——CSV 有**重複欄名**(買入、賣出區段都用 `匯率 / 現金 / 即期``DictReader` 只會保留最後一個同名欄位,所以找不到「本行即期賣出」。
**解法**:改用位置索引,`即期賣出` 在 col 13。
## 階段 2Python GUI最終未採用
計畫:`tkinter` + ttk 美化 + `PyInstaller --onefile --windowed` 打包成 exe。寫了 `bot_rates_gui.py``build.bat`
**問題**:使用者手上沒有 Windows 機器PyInstaller 必須在目標 OS 上執行,無法從 macOS 直接產出 Windows exe。只能給使用者 `build.bat` 要他在 Windows 上自己打包——違背「不要裝東西」的初衷。
## 階段 3改用 Rust
Rust 可以從 macOS 乾淨地交叉編譯到 Windows單一靜態執行檔零 runtime 依賴,剛好解決 Python 方案的痛點。
### 技術選型
| 選擇 | 理由 |
|------|------|
| `eframe` / `egui` | Immediate mode GUI使用 OpenGLWindows 內建),不需要 GTK / Qt 這類系統依賴。交叉編譯幾乎沒坑。 |
| `ureq` + `rustls` | `reqwest` 預設用 `native-tls` 會綁 OpenSSL 或 Windows SChannel交叉編譯會有系統 library 問題。`ureq``rustls` 是純 Rust無系統依賴。 |
| `x86_64-pc-windows-gnu` | 用 mingw-w64 當 linker`-msvc` 少了需要 Windows SDK 的麻煩。`brew install mingw-w64` 就好。 |
### 踩過的坑
**LLVM 在 release 編譯時 SIGSEGV**
```
error: rustc interrupted by SIGSEGV, printing backtrace
llvm::DeadArgumentEliminationPass::run
```
第一次 release 編譯時,`Cargo.toml` 用了 `opt-level = "z"` + `lto = true`LLVM 的 DeadArgumentElimination pass 在處理 `eframe` 依賴時堆疊用光。
**解法**
1.`opt-level``"z"` 改成 `3``lto``true` 改成 `"thin"`(降低 pass 壓力)
2. 加上 `RUST_MIN_STACK=16777216`(把執行緒堆疊從預設 2MB 拉到 16MB
兩個同時做下去就過了。
**CJK 字型**
egui 預設字型不含中文字,幣別名稱會顯示成「豆腐」。
**解法**:不 bundle 字型(會讓 exe 大 5-15MB改在執行期讀取作業系統字型。
- Windows`C:\Windows\Fonts\msjh.ttc`(微軟正黑體)
- macOS`/System/Library/Fonts/PingFang.ttc`
fallback 鏈有多個候選msjhl / msyh / simhei找到第一個能讀的就用。
## 階段 4macOS `.app` bundle
`cargo build --release` 只產出裸執行檔,從 Finder 雙擊會跳 terminal 然後才開 GUI體驗差。
**解法**:手動組 `.app` bundle——其實就是特定目錄結構加一個 `Info.plist`
```
BotRates.app/
├── Contents/
│ ├── Info.plist # 告訴 Finder 這是 app
│ └── MacOS/BotRates # 實際執行檔
```
寫 plist 時標示 `CFBundlePackageType = APPL``CFBundleExecutable = BotRates` 即可。另外跑 `xattr -cr` 清掉 quarantine 屬性避免 Gatekeeper 擋下。
## 階段 5壓縮
`bot_rates.exe` 5.4 MB`zip -9` 後 2.7 MB壓縮率 50%。PE 檔因為有大量重複的 LLVM / rustc 樣板程式碼壓縮效果很好。
## 階段 6金額換算
重新設計每一列為三欄 horizontal layout
```
[幣別+匯率 寬110] [輸入 寬120] [ → ] [TWD 靠右]
```
狀態放在 `App.amounts: HashMap<String, String>`(幣別 → 輸入字串)。為什麼存字串而不是 `f64`?因為使用者輸入過程中可能有中間態(例如 `"100."`、空字串、`"1,000"`),存字串才能保留原樣。
輸入解析用 `parse_amount`,忽略逗號和空白再 parse。
TWD 顯示用 `format_twd`,手刻千分位 separator沒引入 `num-format` crate 以減少 binary size
## 階段 7複製功能
兩種複製方式並存:
1. **按鈕複製**(主要 UX按「複製」→ `ui.output_mut(|o| o.copied_text = raw)`,複製純數字 `3162.00`(不含 `NT$`、不含逗號)。原因:這樣貼到 Excel 會被識別成數字而不是文字。
2. **選取複製**`egui::Label::new(...).selectable(true)`,使用者可以用滑鼠拉選 TWD 文字後 `Cmd/Ctrl+C`
按下按鈕後短暫顯示「已複製」1.2 秒),用 `Option<(String, Instant)>` 追蹤狀態 + `ctx.request_repaint_after` 觸發回復重繪。
## 最終成果
| 項目 | 數值 |
|------|------|
| Windows exe 大小 | 5.4 MBzip 後 2.7 MB |
| macOS 執行檔大小 | 5.2 MB |
| 執行期依賴 | 無(兩平台皆零依賴) |
| 首次冷啟動 | < 1 秒 |
| 抓匯率耗時 | 網路 + 解析 < 500ms |
| 總原始碼行數 | ~310 行 Rust |
## 如果要擴充
- **加更多幣別**:改 `TARGETS` 即可,其他地方都自動處理。
- **換成即期買入 / 現金匯率**:改 `fetch_rates` 裡的 col index3 買入 / 13 賣出 / 2 現金買入 / 12 現金賣出)。
- **雙向換算**TWD → 外幣):在每列加第二個輸入框,狀態多存一組字串;注意避免無限循環更新。
- **歷史紀錄 / 圖表**:台銀有 `flcsv/1/month` 等端點可以抓歷史,但就不是「即期」而是均價了。

BIN
bot_rates.exe Executable file

Binary file not shown.

36
bot_rates.py Normal file
View File

@@ -0,0 +1,36 @@
import csv
import io
import urllib.request
URL = "https://rate.bot.com.tw/xrt/flcsv/0/day"
TARGETS = {"USD": "美元", "JPY": "日幣", "EUR": "歐元"}
def fetch_rates():
with urllib.request.urlopen(URL, timeout=10) as resp:
raw = resp.read().decode("utf-8-sig", errors="replace")
reader = csv.reader(io.StringIO(raw))
next(reader, None)
rates = {}
for row in reader:
if not row:
continue
code = row[0].strip()
if code in TARGETS:
rates[code] = float(row[13])
return rates
def main():
rates = fetch_rates()
print("台灣銀行 即期賣出匯率")
print("-" * 28)
for code, name in TARGETS.items():
if code in rates:
print(f"{name} ({code}): {rates[code]:.4f}")
else:
print(f"{name} ({code}): 查無資料")
if __name__ == "__main__":
main()

BIN
bot_rates.zip Normal file

Binary file not shown.

130
bot_rates_gui.py Normal file
View File

@@ -0,0 +1,130 @@
import csv
import io
import threading
import tkinter as tk
import urllib.request
from datetime import datetime
from tkinter import ttk
URL = "https://rate.bot.com.tw/xrt/flcsv/0/day"
TARGETS = [
("USD", "美元"),
("JPY", "日幣"),
("EUR", "歐元"),
]
def fetch_rates():
req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode("utf-8-sig", errors="replace")
reader = csv.reader(io.StringIO(raw))
next(reader, None)
rates = {}
wanted = {code for code, _ in TARGETS}
for row in reader:
if not row:
continue
code = row[0].strip()
if code in wanted:
rates[code] = float(row[13])
return rates
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("台灣銀行 即期賣出匯率")
self.geometry("360x320")
self.configure(bg="#f5f6fa")
self.resizable(False, False)
style = ttk.Style(self)
try:
style.theme_use("vista")
except tk.TclError:
style.theme_use("clam")
style.configure("TFrame", background="#f5f6fa")
style.configure("Title.TLabel",
background="#f5f6fa",
foreground="#1f2937",
font=("Microsoft JhengHei UI", 14, "bold"))
style.configure("Name.TLabel",
background="#ffffff",
foreground="#374151",
font=("Microsoft JhengHei UI", 12))
style.configure("Rate.TLabel",
background="#ffffff",
foreground="#0f766e",
font=("Consolas", 14, "bold"))
style.configure("Hint.TLabel",
background="#f5f6fa",
foreground="#6b7280",
font=("Microsoft JhengHei UI", 9))
style.configure("Accent.TButton",
font=("Microsoft JhengHei UI", 10, "bold"),
padding=6)
container = ttk.Frame(self, padding=(18, 16))
container.pack(fill="both", expand=True)
ttk.Label(container, text="台灣銀行 即期賣出", style="Title.TLabel").pack(anchor="w")
ttk.Label(container, text="資料來源rate.bot.com.tw",
style="Hint.TLabel").pack(anchor="w", pady=(2, 12))
card = tk.Frame(container, bg="#ffffff", highlightthickness=1,
highlightbackground="#e5e7eb")
card.pack(fill="x")
self.rate_labels = {}
for i, (code, name) in enumerate(TARGETS):
row = tk.Frame(card, bg="#ffffff")
row.pack(fill="x", padx=14, pady=10)
ttk.Label(row, text=f"{name} {code}", style="Name.TLabel").pack(side="left")
lbl = ttk.Label(row, text="--", style="Rate.TLabel")
lbl.pack(side="right")
self.rate_labels[code] = lbl
if i < len(TARGETS) - 1:
tk.Frame(card, bg="#f1f2f6", height=1).pack(fill="x", padx=10)
bottom = ttk.Frame(container)
bottom.pack(fill="x", pady=(14, 0))
self.status = ttk.Label(bottom, text="載入中…", style="Hint.TLabel")
self.status.pack(side="left")
self.btn = ttk.Button(bottom, text="重新整理", style="Accent.TButton",
command=self.refresh)
self.btn.pack(side="right")
self.after(100, self.refresh)
def refresh(self):
self.btn.state(["disabled"])
self.status.configure(text="載入中…", foreground="#6b7280")
threading.Thread(target=self._worker, daemon=True).start()
def _worker(self):
try:
rates = fetch_rates()
self.after(0, self._on_success, rates)
except Exception as e:
self.after(0, self._on_error, e)
def _on_success(self, rates):
for code, _ in TARGETS:
value = rates.get(code)
text = f"{value:.4f}" if value is not None else "查無資料"
self.rate_labels[code].configure(text=text)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.status.configure(text=f"更新時間:{now}", foreground="#6b7280")
self.btn.state(["!disabled"])
def _on_error(self, err):
self.status.configure(text=f"載入失敗:{err}", foreground="#b91c1c")
self.btn.state(["!disabled"])
if __name__ == "__main__":
App().mainloop()

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))),
)
}

29
build.bat Normal file
View File

@@ -0,0 +1,29 @@
@echo off
REM 在 Windows 執行此檔即可產生 dist\bot_rates_gui.exe
REM 需求:已安裝 Python 3.8+(勾選 Add to PATH
where python >nul 2>nul
if errorlevel 1 (
echo [X] 找不到 Python請先到 https://www.python.org/downloads/ 安裝並勾選 Add Python to PATH
pause
exit /b 1
)
echo [1/3] 安裝 PyInstaller...
python -m pip install --upgrade pip >nul
python -m pip install pyinstaller || goto :err
echo [2/3] 打包中...
python -m PyInstaller --noconfirm --onefile --windowed ^
--name bot_rates_gui ^
bot_rates_gui.py || goto :err
echo [3/3] 完成執行檔位置dist\bot_rates_gui.exe
echo 將 dist\bot_rates_gui.exe 複製給使用者,雙擊即可執行。
pause
exit /b 0
:err
echo [X] 打包失敗,請檢查上方錯誤訊息。
pause
exit /b 1