Files
happy-export/frontend/src/components/TimeZoneClock.jsx
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

99 lines
3.4 KiB
React
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.
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