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,285 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
|
||||
import {
|
||||
buildCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
|
||||
} from '../currencies'
|
||||
|
||||
const DEFAULT_FROM = 'USD'
|
||||
const DEFAULT_TO = 'CNY'
|
||||
const PAIR_STORAGE_KEY = 'happy.fxPair'
|
||||
/** 面板开着时的自动刷新间隔(后端本身也有 1 小时缓存,这里只是保证长开着不会太旧)。 */
|
||||
const AUTO_REFRESH_MS = 30 * 60 * 1000
|
||||
|
||||
function readStoredPair() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(PAIR_STORAGE_KEY) || 'null')
|
||||
if (raw && /^[A-Z]{3}$/.test(raw.from) && /^[A-Z]{3}$/.test(raw.to)) return raw
|
||||
} catch { /* 用默认值 */ }
|
||||
return { from: DEFAULT_FROM, to: DEFAULT_TO }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 币种选择器:面板内两侧各一个,带搜索 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function CurrencyPicker({ value, options, onChange, align }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const boxRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { setKeyword(''); return }
|
||||
const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
|
||||
const onPointerDown = event => {
|
||||
if (!boxRef.current?.contains(event.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = keyword.trim().toUpperCase()
|
||||
if (!term) return options
|
||||
return options.filter(item => item.code.includes(term) || (item.name && item.name.includes(keyword.trim())))
|
||||
}, [options, keyword])
|
||||
|
||||
return (
|
||||
<div className={`fx-picker ${align}`} ref={boxRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`fx-picker-btn${open ? ' is-open' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={currencyLabel(value)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<b>{value}</b>
|
||||
<span>{currencyLabel(value).slice(4) || '—'}</span>
|
||||
<ChevronDownIcon size={13} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fx-picker-pop">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="fx-picker-search"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
placeholder="搜索币种 / 代码"
|
||||
aria-label="搜索币种"
|
||||
/>
|
||||
<div className="fx-picker-list" role="listbox">
|
||||
{filtered.length === 0
|
||||
? <div className="fx-picker-empty">没有匹配的币种</div>
|
||||
: filtered.map(item => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.code === value}
|
||||
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
|
||||
onClick={() => { onChange(item.code); setOpen(false) }}
|
||||
>
|
||||
<b>{item.code}</b>
|
||||
<span>{item.name || '—'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 汇率计算器 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function FxCalculator() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [status, setStatus] = useState('idle') // idle | loading | ready | error
|
||||
const [rates, setRates] = useState(null)
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [pair, setPair] = useState(readStoredPair)
|
||||
/* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
|
||||
这样不存在两个输入框互相改、改出抖动的可能。 */
|
||||
const [entry, setEntry] = useState({ value: '1000', side: 'from' })
|
||||
|
||||
const panelRef = useRef(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
const { from, to } = pair
|
||||
|
||||
const loadRates = useCallback(async force => {
|
||||
setStatus(prev => (prev === 'ready' ? prev : 'loading'))
|
||||
try {
|
||||
const response = await axios.get('/api/fx/rates', { params: force ? { force: 1 } : {} })
|
||||
const data = response.data || {}
|
||||
if (!data.ok || !data.rates) throw new Error(data.error || '汇率数据不可用')
|
||||
setRates(data.rates)
|
||||
setMeta({
|
||||
source: data.sourceLabel || data.source || '',
|
||||
updatedAt: data.updatedAt,
|
||||
stale: !!data.stale,
|
||||
warning: data.warning || ''
|
||||
})
|
||||
setError('')
|
||||
setStatus('ready')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.message || '获取汇率失败')
|
||||
setStatus(prev => (rates ? 'ready' : 'error'))
|
||||
}
|
||||
}, [rates])
|
||||
|
||||
/* 首次展开才去取数 —— 没打开过的用户不该为这个功能付一次请求。 */
|
||||
useEffect(() => {
|
||||
if (!open || loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
loadRates(false)
|
||||
}, [open, loadRates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const timer = window.setInterval(() => loadRates(true), AUTO_REFRESH_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [open, loadRates])
|
||||
|
||||
/* 点面板外或按 Esc 关闭 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = event => {
|
||||
if (!panelRef.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])
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
|
||||
}, [pair])
|
||||
|
||||
const options = useMemo(() => buildCurrencyOptions(rates, [DEFAULT_FROM, DEFAULT_TO, 'EUR', 'GBP', 'JPY']), [rates])
|
||||
const rate = crossRate(rates, from, to) // 1 from = ? to
|
||||
const inverse = crossRate(rates, to, from) // 1 to = ? from
|
||||
const num = parseAmount(entry.value)
|
||||
|
||||
const fromDisplay = entry.side === 'from'
|
||||
? entry.value
|
||||
: (num !== null && rate ? formatAmount(num / rate) : '')
|
||||
const toDisplay = entry.side === 'to'
|
||||
? entry.value
|
||||
: (num !== null && rate ? formatAmount(num * rate) : '')
|
||||
|
||||
/* 换币种时锁住「那一侧现在的数字」——符合直觉:1000 美元换成 1000 欧元的输入,
|
||||
再按新汇率算出右边。 */
|
||||
const changeFrom = code => {
|
||||
if (code === from) return
|
||||
const keep = fromDisplay
|
||||
setPair(cur => ({ ...cur, from: code }))
|
||||
setEntry({ value: keep, side: 'from' })
|
||||
}
|
||||
const changeTo = code => {
|
||||
if (code === to) return
|
||||
const keep = toDisplay
|
||||
setPair(cur => ({ ...cur, to: code }))
|
||||
setEntry({ value: keep, side: 'to' })
|
||||
}
|
||||
/* 互换:把右边的结果挪到左边,换算关系保持不变 */
|
||||
const swap = () => {
|
||||
setPair(cur => ({ from: cur.to, to: cur.from }))
|
||||
setEntry({ value: toDisplay, side: 'from' })
|
||||
}
|
||||
|
||||
const updatedLabel = meta?.updatedAt
|
||||
? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="fx-calc" ref={panelRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`header-tool-btn${open ? ' is-open' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title="汇率计算器"
|
||||
aria-label="汇率计算器"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<CalculatorIcon size={19} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fx-panel" role="dialog" aria-label="汇率计算器">
|
||||
<div className="fx-panel-head">
|
||||
<span className="fx-panel-title"><CalculatorIcon size={16} /> 汇率换算</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fx-refresh"
|
||||
onClick={() => loadRates(true)}
|
||||
disabled={status === 'loading'}
|
||||
title="刷新汇率"
|
||||
aria-label="刷新汇率"
|
||||
>
|
||||
<RefreshIcon size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fx-panel-body">
|
||||
<div className="fx-side">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="fx-amount"
|
||||
value={fromDisplay}
|
||||
onChange={event => setEntry({ value: event.target.value, side: 'from' })}
|
||||
placeholder="0"
|
||||
aria-label={`金额(${from})`}
|
||||
/>
|
||||
<CurrencyPicker value={from} options={options} onChange={changeFrom} align="left" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="fx-swap" onClick={swap} title="互换货币" aria-label="互换货币">
|
||||
<SwapIcon size={16} />
|
||||
</button>
|
||||
|
||||
<div className="fx-side">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="fx-amount"
|
||||
value={toDisplay}
|
||||
onChange={event => setEntry({ value: event.target.value, side: 'to' })}
|
||||
placeholder="0"
|
||||
aria-label={`金额(${to})`}
|
||||
/>
|
||||
<CurrencyPicker value={to} options={options} onChange={changeTo} align="right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fx-rate-line">
|
||||
{status === 'loading' && !rates
|
||||
? '正在获取最新汇率…'
|
||||
: status === 'error'
|
||||
? <span className="fx-error">{error}</span>
|
||||
: rate
|
||||
? <>1 {from} = <b>{formatRate(rate)}</b> {to}<em>· 1 {to} = {formatRate(inverse)} {from}</em></>
|
||||
: '—'}
|
||||
</div>
|
||||
|
||||
<div className="fx-panel-foot">
|
||||
{meta?.warning
|
||||
? <span className="fx-warn">{meta.warning}</span>
|
||||
: <span>数据源 {meta?.source || '—'}{updatedLabel ? ` · ${updatedLabel} 更新` : ''}{meta?.stale ? '(已过期)' : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FxCalculator
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { BotIcon, ClockIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
|
||||
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
|
||||
import TimeZoneClock from './TimeZoneClock'
|
||||
import FxCalculator from './FxCalculator'
|
||||
import { HOME_PATH, PAGE_LABELS } from '../navigation'
|
||||
|
||||
const TABS_STORAGE_KEY = 'happy.openTabs'
|
||||
@@ -86,8 +88,6 @@ function GlobalHeader({ onOpenAssistant }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const timeLabel = useMemo(() => now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), [now])
|
||||
const dateLabel = useMemo(() => now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }), [now])
|
||||
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
|
||||
|
||||
return (
|
||||
@@ -116,7 +116,7 @@ function GlobalHeader({ onOpenAssistant }) {
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="global-header-actions"><div className="header-clock"><ClockIcon size={16} /><span>{dateLabel}</span><b>{timeLabel}</b></div><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,6 +209,25 @@ export const ChevronDownIcon = ({ size = 16, className = '' }) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 汇率计算器:机身 + 显示屏 + 按键点阵 */
|
||||
export const CalculatorIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<rect x="4" y="2.5" width="16" height="19" rx="2.6" />
|
||||
<path d="M8 7.2h8" />
|
||||
<path d="M8.2 11.6h.01M12 11.6h.01M15.8 11.6h.01M8.2 15h.01M12 15h.01M15.8 15h.01M8.2 18.4h.01M12 18.4h.01M15.8 18.4h.01" strokeWidth="2.4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 货币互换:上下两条反向箭头 */
|
||||
export const SwapIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M4 8.5h15" />
|
||||
<path d="m15.6 5.2 3.3 3.3-3.3 3.3" />
|
||||
<path d="M20 15.5H5" />
|
||||
<path d="m8.4 12.2-3.3 3.3 3.3 3.3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 客户报价单 */
|
||||
export const QuoteIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
|
||||
@@ -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