Initial commit: 台銀即期賣出匯率換算工具
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
.DS_Store
|
||||
24
BotRates.app/Contents/Info.plist
Normal file
24
BotRates.app/Contents/Info.plist
Normal 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>
|
||||
BIN
BotRates.app/Contents/MacOS/BotRates
Executable file
BIN
BotRates.app/Contents/MacOS/BotRates
Executable file
Binary file not shown.
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal 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 胖 5–15 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
91
QUICKSTART.md
Normal 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
80
README.md
Normal 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.27(immediate 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
126
SUMMARY.md
Normal 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 | 換算結果要能直接複製 | 「複製」按鈕 + 可選取文字 |
|
||||
|
||||
## 階段 1:Python CLI
|
||||
|
||||
直接用 `urllib` 抓台銀 CSV,起初用 `csv.DictReader`,但踩到一個坑——CSV 有**重複欄名**(買入、賣出區段都用 `匯率 / 現金 / 即期`),`DictReader` 只會保留最後一個同名欄位,所以找不到「本行即期賣出」。
|
||||
|
||||
**解法**:改用位置索引,`即期賣出` 在 col 13。
|
||||
|
||||
## 階段 2:Python 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,使用 OpenGL(Windows 內建),不需要 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),找到第一個能讀的就用。
|
||||
|
||||
## 階段 4:macOS `.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 MB(zip 後 2.7 MB) |
|
||||
| macOS 執行檔大小 | 5.2 MB |
|
||||
| 執行期依賴 | 無(兩平台皆零依賴) |
|
||||
| 首次冷啟動 | < 1 秒 |
|
||||
| 抓匯率耗時 | 網路 + 解析 < 500ms |
|
||||
| 總原始碼行數 | ~310 行 Rust |
|
||||
|
||||
## 如果要擴充
|
||||
|
||||
- **加更多幣別**:改 `TARGETS` 即可,其他地方都自動處理。
|
||||
- **換成即期買入 / 現金匯率**:改 `fetch_rates` 裡的 col index(3 買入 / 13 賣出 / 2 現金買入 / 12 現金賣出)。
|
||||
- **雙向換算**(TWD → 外幣):在每列加第二個輸入框,狀態多存一組字串;注意避免無限循環更新。
|
||||
- **歷史紀錄 / 圖表**:台銀有 `flcsv/1/month` 等端點可以抓歷史,但就不是「即期」而是均價了。
|
||||
BIN
bot_rates.exe
Executable file
BIN
bot_rates.exe
Executable file
Binary file not shown.
36
bot_rates.py
Normal file
36
bot_rates.py
Normal 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
BIN
bot_rates.zip
Normal file
Binary file not shown.
130
bot_rates_gui.py
Normal file
130
bot_rates_gui.py
Normal 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()
|
||||
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))),
|
||||
)
|
||||
}
|
||||
29
build.bat
Normal file
29
build.bat
Normal 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
|
||||
Reference in New Issue
Block a user