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
+110
View File
@@ -0,0 +1,110 @@
/**
* 币种清单(顶栏汇率计算器用)
*
* 只是「展示用」的名字与排序。真正有哪些币种、汇率多少,全部以
* 后端 /api/fx/rates 返回的 rates 为准 —— 接口给了 160+ 币种,
* 这份清单里没列到的也会照常出现在下拉里(只显示代码)。
*
* ORDER 决定下拉里的先后(外贸常用币种在前),未列出的按字母序排在后面。
*/
export const CURRENCY_NAMES = {
USD: '美元', CNY: '人民币', EUR: '欧元', GBP: '英镑', HKD: '港币', JPY: '日元',
AUD: '澳元', CAD: '加元', SGD: '新加坡元', CHF: '瑞士法郎', NZD: '新西兰元', KRW: '韩元',
AED: '阿联酋迪拉姆', SAR: '沙特里亚尔', RUB: '卢布', INR: '印度卢比', BRL: '巴西雷亚尔',
MXN: '墨西哥比索', THB: '泰铢', VND: '越南盾', MYR: '马来西亚林吉特', IDR: '印尼盾',
PHP: '菲律宾比索', TRY: '土耳其里拉', ZAR: '南非兰特', PLN: '波兰兹罗提',
SEK: '瑞典克朗', NOK: '挪威克朗', DKK: '丹麦克朗', EGP: '埃及镑', NGN: '尼日利亚奈拉',
PKR: '巴基斯坦卢比', BDT: '孟加拉塔卡', KES: '肯尼亚先令', KWD: '科威特第纳尔',
QAR: '卡塔尔里亚尔', OMR: '阿曼里亚尔', BHD: '巴林第纳尔', ILS: '以色列谢克尔',
TWD: '新台币', CLP: '智利比索', COP: '哥伦比亚比索', PEN: '秘鲁索尔', ARS: '阿根廷比索',
UAH: '乌克兰格里夫纳', CZK: '捷克克朗', HUF: '匈牙利福林', RON: '罗马尼亚列伊',
LKR: '斯里兰卡卢比', NPR: '尼泊尔卢比', MMK: '缅甸元', KHR: '柬埔寨瑞尔',
TZS: '坦桑尼亚先令', MAD: '摩洛哥迪拉姆', GHS: '加纳塞地', ETB: '埃塞俄比亚比尔',
UGX: '乌干达先令', ZMW: '赞比亚克瓦查', DZD: '阿尔及利亚第纳尔', TND: '突尼斯第纳尔',
JOD: '约旦第纳尔', IQD: '伊拉克第纳尔', LBP: '黎巴嫩镑', AZN: '阿塞拜疆马纳特',
KZT: '哈萨克斯坦坚戈', UZS: '乌兹别克斯坦苏姆', GEL: '格鲁吉亚拉里', AMD: '亚美尼亚德拉姆',
MNT: '蒙古图格里克', KGS: '吉尔吉斯斯坦索姆', BND: '文莱元', MOP: '澳门元',
ISK: '冰岛克朗', UYU: '乌拉圭比索', BOB: '玻利维亚诺', PYG: '巴拉圭瓜拉尼',
CRC: '哥斯达黎加科朗', GTQ: '危地马拉格查尔', DOP: '多米尼加比索', JMD: '牙买加元',
TTD: '特立尼达和多巴哥元', RSD: '塞尔维亚第纳尔', BGN: '保加利亚列弗',
HRK: '克罗地亚库纳', ALL: '阿尔巴尼亚列克', MKD: '北马其顿第纳尔'
}
/** 下拉排序权重:外贸结算最常见的排前面。 */
const ORDER = [
'USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY', 'AUD', 'CAD', 'SGD', 'CHF', 'NZD', 'KRW',
'AED', 'SAR', 'RUB', 'INR', 'BRL', 'MXN', 'THB', 'VND', 'MYR', 'IDR', 'PHP', 'TRY',
'ZAR', 'PLN', 'SEK', 'NOK', 'DKK', 'TWD', 'EGP', 'NGN', 'PKR', 'BDT', 'KES', 'KWD',
'QAR', 'OMR', 'BHD', 'ILS', 'CLP', 'COP', 'PEN', 'ARS', 'UAH', 'CZK', 'HUF', 'RON'
]
const RANK = new Map(ORDER.map((code, index) => [code, index]))
/**
* 把接口返回的 rates 变成下拉选项,按常用度排序。
* @param {Record<string, number>} rates 以 USD 为基准的汇率表
* @param {string[]} extra 兜底币种(接口没回来时也要有 USD/CNY 可选)
*/
export function buildCurrencyOptions(rates, extra = ['USD', 'CNY']) {
const codes = Object.keys(rates || {})
const merged = Array.from(new Set([...extra, ...codes]))
return merged
.map(code => ({ code, name: CURRENCY_NAMES[code] || '' }))
.sort((a, b) => {
const ra = RANK.has(a.code) ? RANK.get(a.code) : 999
const rb = RANK.has(b.code) ? RANK.get(b.code) : 999
if (ra !== rb) return ra - rb
return a.code.localeCompare(b.code)
})
}
/** "USD 美元" / 没中文名时只显示代码 */
export function currencyLabel(code) {
const name = CURRENCY_NAMES[code]
return name ? `${code} ${name}` : code
}
/**
* 交叉汇率:1 from = ? to
* rates 以 USD 为基准,所以 rates[to] / rates[from] 就是任意两币之间的汇率。
*/
export function crossRate(rates, from, to) {
if (!rates) return null
const rf = Number(rates[from])
const rt = Number(rates[to])
if (!Number.isFinite(rf) || !Number.isFinite(rt) || rf <= 0) return null
return rt / rf
}
/**
* 金额显示:汇率结果的有效位数差别很大
* (1 CNY ≈ 0.00004 USD,1 USD ≈ 25000 VND),
* 固定两位小数会让小额变 0、大额全是没意义的零,所以按数量级取精度。
*/
export function formatAmount(value) {
if (!Number.isFinite(value)) return ''
const abs = Math.abs(value)
let digits
if (abs === 0) digits = 2
else if (abs >= 1000) digits = 2
else if (abs >= 1) digits = 4
else if (abs >= 0.001) digits = 6
else digits = 8
return value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: digits })
}
/** 汇率本身用 6 位有效数字展示:0.000131 这种小汇率也能看清。 */
export function formatRate(value) {
if (!Number.isFinite(value)) return ''
if (value >= 1000) return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
if (value >= 1) return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
return value.toPrecision(4)
}
/** 把输入框里的字符串转成数字;空串、半截输入("1.")都返回 null 而不是崩掉。 */
export function parseAmount(text) {
const cleaned = String(text ?? '').replace(/[,\s]/g, '')
if (!cleaned) return null
const num = Number(cleaned)
return Number.isFinite(num) ? num : null
}