diff --git a/.gitignore b/.gitignore
index 6f80278..67173cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,9 +12,9 @@ build/
!.env.example
# ---------- 数据库与运行时数据 ----------
-backend/data/*.db
-backend/data/*.db-*
-backend/data/*.sqlite*
+# backend/data/ 放的是运行时产物(trade.db、汇率缓存 fx-cache.json 等),
+# 全是本机生成的数据,一律不入库;新环境部署时由服务自动创建。
+backend/data/
backend/uploads/
# ---------- 日志 ----------
diff --git a/backend/lib/fx.js b/backend/lib/fx.js
new file mode 100644
index 0000000..ae8f729
--- /dev/null
+++ b/backend/lib/fx.js
@@ -0,0 +1,202 @@
+/**
+ * happy 出口通 —— 汇率取数
+ *
+ * 目标:给顶栏的汇率计算器提供**任意两种货币之间**的换算汇率。
+ *
+ * 设计要点:
+ * 1. 所有汇率统一以 USD 为基准存取(rates[X] = 1 USD 值多少 X),
+ * 任意币种对 (from → to) 用 rates[to] / rates[from] 即可算出交叉汇率。
+ * 2. 两个公开数据源按顺序尝试,任一成功即用:
+ * - open.er-api.com 免费无需 key,约 160+ 币种,带更新日期
+ * - frankfurter.app 欧洲央行数据,约 30 币种,权威但覆盖面小
+ * 3. 结果写内存 + 落盘(backend/data/fx-cache.json,该目录已 gitignore)。
+ * 重启后仍能立刻给出上次的汇率,不必等网络。
+ * 4. 网络彻底不可用时回退内置离线汇率表,并在响应里标注 source='offline',
+ * 让前端能明确提示「这是离线参考值」。宁可给一个标清楚来源的估算,
+ * 也不要让计算器变成一块空白。
+ */
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
+
+/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
+const TTL_MS = 60 * 60 * 1000;
+/** 单个数据源的超时。 */
+const TIMEOUT_MS = 8000;
+const USER_AGENT = 'happy-export-fx';
+
+/* ------------------------------ 数据源 ------------------------------ */
+
+/**
+ * 每个数据源负责把自家返回体归一成 { rates, updatedAt }。
+ * rates 必须以 USD 为基准,键为大写三位币种码。
+ */
+const PROVIDERS = [
+ {
+ id: 'open.er-api.com',
+ label: 'ExchangeRate-API',
+ url: 'https://open.er-api.com/v6/latest/USD',
+ pick(json) {
+ if (!json || json.result === 'error' || !json.rates) return null;
+ const unix = Number(json.time_last_update_unix);
+ return {
+ rates: json.rates,
+ // 这个源会给到精确到秒的更新时间;没有就退回 next_update 或当前时间
+ updatedAt: Number.isFinite(unix) && unix > 0
+ ? new Date(unix * 1000).toISOString()
+ : new Date().toISOString()
+ };
+ }
+ },
+ {
+ id: 'frankfurter.app',
+ label: 'Frankfurter(欧洲央行)',
+ url: 'https://api.frankfurter.app/latest?from=USD',
+ pick(json) {
+ if (!json || !json.rates) return null;
+ return {
+ // 这个源不返回基准货币自身,补上 USD: 1
+ rates: { USD: 1, ...json.rates },
+ updatedAt: json.date ? new Date(`${json.date}T00:00:00Z`).toISOString() : new Date().toISOString()
+ };
+ }
+ }
+];
+
+/**
+ * 离线兜底汇率(1 USD 值多少目标币)。
+ * 仅在两个数据源都拿不到、且本地也没有任何历史缓存时启用。
+ * 数值是写这份代码时的市场中间价量级,只适合粗估,响应里会明确标注。
+ */
+const OFFLINE_RATES = {
+ USD: 1,
+ CNY: 7.10, HKD: 7.80, TWD: 32.2, JPY: 152, KRW: 1360, SGD: 1.34,
+ EUR: 0.92, GBP: 0.78, CHF: 0.88, SEK: 10.5, NOK: 10.7, DKK: 6.88,
+ PLN: 3.95, CZK: 23.0, TRY: 34.0, RUB: 92.0,
+ AUD: 1.51, NZD: 1.64, CAD: 1.37, MXN: 18.5, BRL: 5.55, ARS: 950, CLP: 930,
+ INR: 83.5, PKR: 278, BDT: 118, LKR: 300, NPR: 134, VND: 24800,
+ THB: 34.5, MYR: 4.45, IDR: 15600, PHP: 56.5, KHR: 4100, MMK: 2100,
+ AED: 3.6725, SAR: 3.75, QAR: 3.64, KWD: 0.307, OMR: 0.385, ILS: 3.72,
+ EGP: 48.5, ZAR: 18.2, NGN: 1600, KES: 129, MAD: 9.85, TZS: 2700,
+ UAH: 41.0, RON: 4.58, HUF: 360, BGN: 1.80, ISK: 138, GEL: 2.72
+};
+
+/* ------------------------------ 工具 ------------------------------ */
+
+/** 只保留「键是 3 位字母、值是正数」的条目,顺手统一成大写。 */
+function sanitizeRates(raw) {
+ const out = {};
+ if (!raw || typeof raw !== 'object') return out;
+ for (const [key, value] of Object.entries(raw)) {
+ const code = String(key).trim().toUpperCase();
+ const num = Number(value);
+ if (/^[A-Z]{3}$/.test(code) && Number.isFinite(num) && num > 0) out[code] = num;
+ }
+ out.USD = 1;
+ return out;
+}
+
+function readDiskCache() {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
+ const rates = sanitizeRates(parsed && parsed.rates);
+ if (Object.keys(rates).length > 1) {
+ return {
+ rates,
+ updatedAt: parsed.updatedAt || null,
+ fetchedAt: Number(parsed.fetchedAt) || 0,
+ source: parsed.source || 'cache'
+ };
+ }
+ } catch {
+ /* 没有缓存或文件损坏都走正常取数流程 */
+ }
+ return null;
+}
+
+function writeDiskCache(payload) {
+ try {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8');
+ } catch {
+ /* 写不进去不影响本次换算,只是下次重启享受不到缓存 */
+ }
+}
+
+/** 带超时的 GET,返回解析后的 JSON 对象或 null。 */
+async function fetchJson(url) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const response = await fetch(url, {
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
+ signal: controller.signal
+ });
+ if (!response.ok) return null;
+ return await response.json();
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/* ------------------------------ 主流程 ------------------------------ */
+
+let memory = readDiskCache();
+/** 同一个进程里并发调用只发一次网络请求。 */
+let inflight = null;
+
+async function refresh() {
+ for (const provider of PROVIDERS) {
+ const json = await fetchJson(provider.url);
+ const picked = json && provider.pick(json);
+ if (!picked) continue;
+ const rates = sanitizeRates(picked.rates);
+ if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源
+ const payload = {
+ rates,
+ updatedAt: picked.updatedAt,
+ fetchedAt: Date.now(),
+ source: provider.id,
+ sourceLabel: provider.label
+ };
+ memory = payload;
+ writeDiskCache(payload);
+ return payload;
+ }
+ return null;
+}
+
+/**
+ * 取汇率表。永远不抛异常。
+ * @param {boolean} force 为 true 时忽略缓存有效期,强制刷新
+ */
+async function getRates(force = false) {
+ const fresh = memory && Date.now() - memory.fetchedAt < TTL_MS;
+ if (fresh && !force) return { ...memory, stale: false, cached: true };
+
+ if (!inflight) {
+ inflight = refresh().finally(() => { inflight = null; });
+ }
+ const refreshed = await inflight;
+ if (refreshed) return { ...refreshed, stale: false, cached: false };
+
+ // 网络失败:有旧数据就先用旧数据,但要标出来
+ if (memory) return { ...memory, stale: true, cached: true, warning: '汇率刷新失败,显示的是上次成功获取的数据' };
+
+ return {
+ rates: OFFLINE_RATES,
+ updatedAt: null,
+ fetchedAt: Date.now(),
+ source: 'offline',
+ sourceLabel: '内置离线汇率',
+ stale: true,
+ cached: false,
+ warning: '当前无法连接汇率服务,显示的是内置离线参考值'
+ };
+}
+
+module.exports = { getRates, PROVIDERS, OFFLINE_RATES };
diff --git a/backend/routes/fx.js b/backend/routes/fx.js
new file mode 100644
index 0000000..2c3ae23
--- /dev/null
+++ b/backend/routes/fx.js
@@ -0,0 +1,31 @@
+/**
+ * 汇率接口。
+ * 挂载点:/api/fx
+ */
+const express = require('express');
+const fx = require('../lib/fx');
+
+const router = express.Router();
+
+/**
+ * GET /api/fx/rates?force=1
+ *
+ * 返回以 USD 为基准的完整汇率表:
+ * { ok, base: 'USD', rates: { USD:1, CNY:7.1, ... }, updatedAt, source, sourceLabel,
+ * stale, cached, warning? }
+ *
+ * 前端拿到后自行做交叉汇率:amount * rates[to] / rates[from]。
+ * 一次请求覆盖所有币种,切货币不再发请求。
+ */
+router.get('/rates', async (req, res) => {
+ try {
+ const force = String(req.query.force || '') === '1';
+ const data = await fx.getRates(force);
+ res.json({ ok: true, base: 'USD', count: Object.keys(data.rates).length, ...data });
+ } catch (error) {
+ // getRates 内部已兜底,走到这里属于意料之外
+ res.status(500).json({ ok: false, error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/backend/server.js b/backend/server.js
index ca483ba..ae2880b 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -5,6 +5,7 @@ const http = require('http');
const db = require('./db/db');
const updater = require('./lib/updater');
const systemRoutes = require('./routes/system');
+const fxRoutes = require('./routes/fx');
const app = express();
const PORT = process.env.PORT || 4300;
@@ -81,6 +82,9 @@ app.get('/api/health', (req, res) => {
// 系统信息与版本更新检查
app.use('/api/system', systemRoutes);
+// 汇率(顶栏汇率计算器用)
+app.use('/api/fx', fxRoutes);
+
// 获取工作台数据
app.get('/api/dashboard', (req, res) => {
try {
diff --git a/frontend/src/components/FxCalculator.jsx b/frontend/src/components/FxCalculator.jsx
new file mode 100644
index 0000000..35b20f2
--- /dev/null
+++ b/frontend/src/components/FxCalculator.jsx
@@ -0,0 +1,285 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import axios from 'axios'
+import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
+import {
+ buildCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
+} from '../currencies'
+
+const DEFAULT_FROM = 'USD'
+const DEFAULT_TO = 'CNY'
+const PAIR_STORAGE_KEY = 'happy.fxPair'
+/** 面板开着时的自动刷新间隔(后端本身也有 1 小时缓存,这里只是保证长开着不会太旧)。 */
+const AUTO_REFRESH_MS = 30 * 60 * 1000
+
+function readStoredPair() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(PAIR_STORAGE_KEY) || 'null')
+ if (raw && /^[A-Z]{3}$/.test(raw.from) && /^[A-Z]{3}$/.test(raw.to)) return raw
+ } catch { /* 用默认值 */ }
+ return { from: DEFAULT_FROM, to: DEFAULT_TO }
+}
+
+/* ------------------------------------------------------------------ */
+/* 币种选择器:面板内两侧各一个,带搜索 */
+/* ------------------------------------------------------------------ */
+function CurrencyPicker({ value, options, onChange, align }) {
+ const [open, setOpen] = useState(false)
+ const [keyword, setKeyword] = useState('')
+ const boxRef = useRef(null)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) { setKeyword(''); return }
+ const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
+ const onPointerDown = event => {
+ if (!boxRef.current?.contains(event.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
+ }, [open])
+
+ const filtered = useMemo(() => {
+ const term = keyword.trim().toUpperCase()
+ if (!term) return options
+ return options.filter(item => item.code.includes(term) || (item.name && item.name.includes(keyword.trim())))
+ }, [options, keyword])
+
+ return (
+
+
+ {open && (
+
+
setKeyword(event.target.value)}
+ placeholder="搜索币种 / 代码"
+ aria-label="搜索币种"
+ />
+
+ {filtered.length === 0
+ ?
没有匹配的币种
+ : filtered.map(item => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* 汇率计算器 */
+/* ------------------------------------------------------------------ */
+function FxCalculator() {
+ const [open, setOpen] = useState(false)
+ const [status, setStatus] = useState('idle') // idle | loading | ready | error
+ const [rates, setRates] = useState(null)
+ const [meta, setMeta] = useState(null)
+ const [error, setError] = useState('')
+ const [pair, setPair] = useState(readStoredPair)
+ /* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
+ 这样不存在两个输入框互相改、改出抖动的可能。 */
+ const [entry, setEntry] = useState({ value: '1000', side: 'from' })
+
+ const panelRef = useRef(null)
+ const loadedRef = useRef(false)
+
+ const { from, to } = pair
+
+ const loadRates = useCallback(async force => {
+ setStatus(prev => (prev === 'ready' ? prev : 'loading'))
+ try {
+ const response = await axios.get('/api/fx/rates', { params: force ? { force: 1 } : {} })
+ const data = response.data || {}
+ if (!data.ok || !data.rates) throw new Error(data.error || '汇率数据不可用')
+ setRates(data.rates)
+ setMeta({
+ source: data.sourceLabel || data.source || '',
+ updatedAt: data.updatedAt,
+ stale: !!data.stale,
+ warning: data.warning || ''
+ })
+ setError('')
+ setStatus('ready')
+ } catch (err) {
+ setError(err.response?.data?.error || err.message || '获取汇率失败')
+ setStatus(prev => (rates ? 'ready' : 'error'))
+ }
+ }, [rates])
+
+ /* 首次展开才去取数 —— 没打开过的用户不该为这个功能付一次请求。 */
+ useEffect(() => {
+ if (!open || loadedRef.current) return
+ loadedRef.current = true
+ loadRates(false)
+ }, [open, loadRates])
+
+ useEffect(() => {
+ if (!open) return
+ const timer = window.setInterval(() => loadRates(true), AUTO_REFRESH_MS)
+ return () => window.clearInterval(timer)
+ }, [open, loadRates])
+
+ /* 点面板外或按 Esc 关闭 */
+ useEffect(() => {
+ if (!open) return
+ const onPointerDown = event => {
+ if (!panelRef.current?.contains(event.target)) setOpen(false)
+ }
+ const onKeyDown = event => { if (event.key === 'Escape') setOpen(false) }
+ document.addEventListener('mousedown', onPointerDown)
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [open])
+
+ useEffect(() => {
+ try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
+ }, [pair])
+
+ const options = useMemo(() => buildCurrencyOptions(rates, [DEFAULT_FROM, DEFAULT_TO, 'EUR', 'GBP', 'JPY']), [rates])
+ const rate = crossRate(rates, from, to) // 1 from = ? to
+ const inverse = crossRate(rates, to, from) // 1 to = ? from
+ const num = parseAmount(entry.value)
+
+ const fromDisplay = entry.side === 'from'
+ ? entry.value
+ : (num !== null && rate ? formatAmount(num / rate) : '')
+ const toDisplay = entry.side === 'to'
+ ? entry.value
+ : (num !== null && rate ? formatAmount(num * rate) : '')
+
+ /* 换币种时锁住「那一侧现在的数字」——符合直觉:1000 美元换成 1000 欧元的输入,
+ 再按新汇率算出右边。 */
+ const changeFrom = code => {
+ if (code === from) return
+ const keep = fromDisplay
+ setPair(cur => ({ ...cur, from: code }))
+ setEntry({ value: keep, side: 'from' })
+ }
+ const changeTo = code => {
+ if (code === to) return
+ const keep = toDisplay
+ setPair(cur => ({ ...cur, to: code }))
+ setEntry({ value: keep, side: 'to' })
+ }
+ /* 互换:把右边的结果挪到左边,换算关系保持不变 */
+ const swap = () => {
+ setPair(cur => ({ from: cur.to, to: cur.from }))
+ setEntry({ value: toDisplay, side: 'from' })
+ }
+
+ const updatedLabel = meta?.updatedAt
+ ? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+ : ''
+
+ return (
+
+
+
+ {open && (
+
+
+ 汇率换算
+
+
+
+
+
+ setEntry({ value: event.target.value, side: 'from' })}
+ placeholder="0"
+ aria-label={`金额(${from})`}
+ />
+
+
+
+
+
+
+ setEntry({ value: event.target.value, side: 'to' })}
+ placeholder="0"
+ aria-label={`金额(${to})`}
+ />
+
+
+
+
+
+ {status === 'loading' && !rates
+ ? '正在获取最新汇率…'
+ : status === 'error'
+ ? {error}
+ : rate
+ ? <>1 {from} = {formatRate(rate)} {to}· 1 {to} = {formatRate(inverse)} {from}>
+ : '—'}
+
+
+
+ {meta?.warning
+ ? {meta.warning}
+ : 数据源 {meta?.source || '—'}{updatedLabel ? ` · ${updatedLabel} 更新` : ''}{meta?.stale ? '(已过期)' : ''}}
+
+
+ )}
+
+ )
+}
+
+export default FxCalculator
diff --git a/frontend/src/components/GlobalHeader.jsx b/frontend/src/components/GlobalHeader.jsx
index 407af9e..8dc9ab9 100644
--- a/frontend/src/components/GlobalHeader.jsx
+++ b/frontend/src/components/GlobalHeader.jsx
@@ -1,7 +1,9 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
-import { BotIcon, ClockIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
+import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
+import TimeZoneClock from './TimeZoneClock'
+import FxCalculator from './FxCalculator'
import { HOME_PATH, PAGE_LABELS } from '../navigation'
const TABS_STORAGE_KEY = 'happy.openTabs'
@@ -86,8 +88,6 @@ function GlobalHeader({ onOpenAssistant }) {
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
- const timeLabel = useMemo(() => now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), [now])
- const dateLabel = useMemo(() => now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }), [now])
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
return (
@@ -116,7 +116,7 @@ function GlobalHeader({ onOpenAssistant }) {
)
})}
- {dateLabel}{timeLabel}
+
)
}
diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx
index cd2f1c5..2a6b551 100644
--- a/frontend/src/components/Icons.jsx
+++ b/frontend/src/components/Icons.jsx
@@ -209,6 +209,25 @@ export const ChevronDownIcon = ({ size = 16, className = '' }) => (
)
+/* 汇率计算器:机身 + 显示屏 + 按键点阵 */
+export const CalculatorIcon = ({ size = 20, className = '' }) => (
+
+)
+
+/* 货币互换:上下两条反向箭头 */
+export const SwapIcon = ({ size = 20, className = '' }) => (
+
+)
+
/* 客户报价单 */
export const QuoteIcon = ({ size = 20, className = '' }) => (