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
+98
View File
@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDownIcon, ClockIcon } from './Icons'
import {
TIMEZONES, TIMEZONE_STORAGE_KEY, findTimeZone, formatDateIn, formatOffsetIn, formatTimeIn, readStoredTimeZone
} from '../timezones'
/**
* 顶栏时钟:显示所选时区的时间,点一下可以换时区。
*
* 时间由父组件每秒 tick 后以 now 传入 —— 顶栏本来就在跑秒表,
* 这里不再自己起一个 interval,避免同一屏里两个定时器各跳各的。
*/
function TimeZoneClock({ now }) {
const [zoneKey, setZoneKey] = useState(readStoredTimeZone)
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
const listRef = useRef(null)
const zone = findTimeZone(zoneKey)
useEffect(() => {
try { localStorage.setItem(TIMEZONE_STORAGE_KEY, zoneKey) } catch { /* 忽略 */ }
}, [zoneKey])
/* 列表比可视区高,打开时滚到当前选中的那一条,否则要自己翻半天找 */
useEffect(() => {
if (!open) return
const active = listRef.current?.querySelector('.clock-zone-item.active')
active?.scrollIntoView({ block: 'nearest' })
}, [open])
useEffect(() => {
if (!open) return
const onPointerDown = event => {
if (!boxRef.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])
const time = formatTimeIn(now, zone.key)
const date = formatDateIn(now, zone.key)
return (
<div className="header-clock" ref={boxRef}>
<button
type="button"
className={`clock-trigger${open ? ' is-open' : ''}`}
onClick={() => setOpen(v => !v)}
title={`当前时区:${zone.zone}:${zone.cities} · 点击切换`}
aria-haspopup="listbox"
aria-expanded={open}
>
<ClockIcon size={17} />
<span className="clock-zone">{zone.zone}:{zone.cities}</span>
<ChevronDownIcon size={13} className="clock-caret" />
<span className="clock-readout">
<b className="clock-time">{time}</b>
<em className="clock-date">{date}</em>
</span>
</button>
{open && (
<div className="clock-pop" role="listbox" aria-label="选择时区">
<div className="clock-pop-head">客户所在地时间</div>
<div className="clock-pop-list" ref={listRef}>
{TIMEZONES.map(item => {
const active = item.key === zoneKey
return (
<button
key={item.key}
type="button"
role="option"
aria-selected={active}
className={`clock-zone-item${active ? ' active' : ''}`}
onClick={() => { setZoneKey(item.key); setOpen(false) }}
>
<span className="clock-zone-name">{item.zone}:{item.cities}</span>
<span className="clock-zone-right">
<b>{formatTimeIn(now, item.key)}</b>
<em>{formatOffsetIn(now, item.key)}</em>
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
export default TimeZoneClock