Files
happy-export/backend/lib/fx.js
T
Drsakura 46a963e23d feat(header): 顶栏时钟可切时区 + 汇率计算器;工作台移除运行日期
1) 工作台
   - 删掉欢迎区右侧的「运行日期」卡片,含 luxury.css 里对应样式与窄屏残留

2) 顶栏时钟(仿小满 CRM)
   - 新增 TimeZoneClock:时钟图标 +「东八区:北京,上海 ⌄」+ 大号时间(20px 等宽,
     tabular-nums 保证秒位不跳动),下方小字带日期
   - 点整块展开时区列表,23 个时区由西向东排列,每行右侧实时显示该地时间与 UTC 偏移
   - 时区用 IANA 名(Europe/London 等)而非写死偏移,夏令时自动正确;
     打开列表会自动滚到当前选中项;选择记 localStorage('happy.clockZone')
   - 新增 src/timezones.js 集中时区表与格式化工具

3) 顶栏汇率计算器
   - 新增 FxCalculator:计算器图标点开浮层,默认 USD→CNY
   - 左右两侧各带可搜索的币种下拉(币种来自接口返回的全部币种,常用币种排前),
     中间一键互换;任一框输入另一框实时反算;显示主汇率与倒数汇率
   - 换币种时锁住所改那一侧的数字(与 Google 换算一致)
   - 金额按数量级自动取精度(USD→VND 与 CNY→USD 都不出现一堆无意义的零)
   - 新增 src/currencies.js(币种中文名 + 排序 + 交叉汇率与格式化)

4) 后端汇率接口
   - 新增 /api/fx/rates:以 USD 为基准返回全量汇率,前端据此算任意币种交叉汇率
   - 主源 open.er-api.com(166 币种),备源 frankfurter.app(欧洲央行);
     内存 + 落盘缓存 1 小时,并发请求只发一次网络
   - 网络不可用时依次回退「上次成功的数据」→「内置离线汇率表」,
     并在响应里用 source/warning 标注,前端明确提示数据来源

5) 顶栏排布
   - 时钟变宽后中等屏会溢出,新增 ≤1440px 断点收紧留白/品牌区/搜索框;
     搜索框允许收缩;1280/1100/980/700 各档实测无横向溢出
   - 移动端(≤680px)汇率浮层改用固定定位铺满两侧,避免左侧被裁

6) .gitignore
   - backend/data/ 整体忽略(trade.db、fx-cache.json 等全是运行时产物)
2026-09-10 17:44:36 +08:00

203 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };