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 等全是运行时产物)
This commit is contained in:
Drsakura
2026-09-10 17:44:36 +08:00
parent 1edf5ac995
commit 46a963e23d
12 changed files with 938 additions and 17 deletions
+202
View File
@@ -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 };
+31
View File
@@ -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;
+4
View File
@@ -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 {