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:
@@ -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
|
||||
Reference in New Issue
Block a user