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
|
||||
@@ -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
|
||||
}
|
||||
@@ -97,7 +97,7 @@ function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div><div className="dashboard-date-chip"><span>运行日期</span><strong>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</strong></div></div>
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
|
||||
|
||||
<CalendarBoard todos={todos} />
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.header-status-dot { width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.global-search { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; width: min(360px, 30vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 1px solid var(--border-light); border-radius: 11px; transition: border-color 150ms, box-shadow 150ms; }
|
||||
.global-search:focus-within { border-color: var(--primary-border); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
.global-search input { min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
|
||||
.global-search input { min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
|
||||
.global-search input:focus { box-shadow: none; }
|
||||
.global-search kbd { margin-left: auto; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 10px var(--font-mono); }
|
||||
/* ---- 已打开页面标签:搜索框后的快速跳转(参考 GoodJob 的顶部标签条) ---- */
|
||||
@@ -245,8 +245,27 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.page-tab.active .page-tab-close { color: var(--text-secondary); }
|
||||
.page-tab.active .page-tab-close:hover { color: var(--danger); }
|
||||
.global-header-actions { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; margin-left: auto; }
|
||||
.header-clock { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--text-tertiary); font: 11px var(--font-mono); white-space: nowrap; }
|
||||
.header-clock b { color: var(--text-primary); font-size: 13px; font-weight: 500; }
|
||||
.header-clock { position: relative; flex: 0 0 auto; }
|
||||
.clock-trigger { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid transparent; border-radius: 11px; background: transparent; color: var(--text-tertiary); cursor: pointer; white-space: nowrap; transition: background 150ms, border-color 150ms; }
|
||||
.clock-trigger:hover, .clock-trigger.is-open { background: var(--surface-hover); border-color: var(--border-light); }
|
||||
.clock-trigger > svg:first-child { flex: 0 0 auto; }
|
||||
.clock-zone { color: var(--text-secondary); font-size: 11.5px; letter-spacing: .01em; }
|
||||
.clock-caret { flex: 0 0 auto; color: var(--text-tertiary); transition: transform 180ms; }
|
||||
.clock-trigger.is-open .clock-caret { transform: rotate(180deg); }
|
||||
.clock-readout { display: grid; justify-items: end; gap: 2px; }
|
||||
.clock-time { color: var(--text-primary); font: 600 20px/1 var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
|
||||
.clock-date { color: var(--text-tertiary); font-size: 10px; font-style: normal; line-height: 1; }
|
||||
.clock-pop { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: 318px; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
|
||||
.clock-pop-head { padding: 8px 10px 7px; color: var(--text-tertiary); font-size: 10px; letter-spacing: .1em; }
|
||||
.clock-pop-list { max-height: 344px; overflow-y: auto; }
|
||||
.clock-zone-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; text-align: left; transition: background 120ms, color 120ms; }
|
||||
.clock-zone-item:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.clock-zone-item.active { background: var(--primary-bg); color: var(--text-primary); }
|
||||
.clock-zone-name { white-space: nowrap; }
|
||||
.clock-zone-right { display: flex; flex: 0 0 auto; align-items: baseline; gap: 7px; }
|
||||
.clock-zone-right b { color: var(--text-primary); font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.clock-zone-item.active .clock-zone-right b { color: var(--primary-light); }
|
||||
.clock-zone-right em { color: var(--text-tertiary); font-size: 9.5px; font-style: normal; }
|
||||
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 150ms, border-color 150ms, color 150ms; }
|
||||
.quick-create-btn:hover, .mobile-search-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
|
||||
.ai-entry-btn { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); box-shadow: 0 0 18px var(--accent-glow); }
|
||||
@@ -259,9 +278,6 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.dashboard-welcome { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
|
||||
.dashboard-welcome .page-title { margin: 0; }
|
||||
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 13px; }
|
||||
.dashboard-date-chip { display: grid; gap: 5px; min-width: 150px; padding: 12px 14px; border-radius: 12px; background: var(--surface); box-shadow: var(--panel-shadow); text-align: right; }
|
||||
.dashboard-date-chip span { color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
|
||||
.dashboard-date-chip strong { color: var(--text-primary); font-size: 13px; font-weight: 600; }
|
||||
.dashboard-panel { margin-bottom: 18px; padding: 20px; border-radius: 16px; background: var(--surface); box-shadow: var(--panel-shadow); }
|
||||
.dashboard-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.dashboard-panel-header h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-primary); font-size: 15px; }
|
||||
@@ -329,9 +345,57 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.ai-message-list { display: grid; gap: 13px; margin-top: 14px; }.ai-message { display: flex; align-items: flex-start; gap: 8px; }.ai-message.user { flex-direction: row-reverse; }.ai-message-avatar { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 25px; border-radius: 9px; background: var(--surface-deep); color: var(--primary-light); font-size: 10px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 9px 11px; border-radius: 4px 13px 13px 13px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 13px 4px 13px 13px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 9px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 34px; }.ai-thinking span { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); animation: ai-bounce 1s infinite ease-in-out; }.ai-thinking span:nth-child(2) { animation-delay: .15s; }.ai-thinking span:nth-child(3) { animation-delay: .3s; }@keyframes ai-bounce { 0%,80%,100% { transform: translateY(0); opacity:.45; } 40% { transform: translateY(-4px); opacity:1; } }
|
||||
.ai-suggestion-row { display: flex; gap: 7px; padding: 8px 20px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 10px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
|
||||
.ai-input-row { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 40px; background: var(--surface-deep); }.ai-input-row button { display: grid; place-items: center; width: 40px; min-width: 40px; border: 0; border-radius: 11px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 8px 20px 13px; color: var(--text-tertiary); font-size: 10px; text-align: center; }
|
||||
/* ---------- 汇率计算器(顶栏) ---------- */
|
||||
.fx-calc { position: relative; flex: 0 0 auto; }
|
||||
.header-tool-btn { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background 150ms, border-color 150ms, color 150ms; }
|
||||
.header-tool-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
|
||||
.header-tool-btn.is-open { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); }
|
||||
.fx-panel { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: min(396px, calc(100vw - 24px)); padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
|
||||
.fx-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.fx-panel-title { display: inline-flex; align-items: center; gap: 7px; color: var(--text-primary); font-size: 12.5px; font-weight: 600; }
|
||||
.fx-refresh { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 1px solid var(--border-light); border-radius: 8px; background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color 150ms, border-color 150ms; }
|
||||
.fx-refresh:hover { color: var(--primary-light); border-color: var(--primary-border); }
|
||||
.fx-refresh:disabled { opacity: .45; cursor: default; }
|
||||
.fx-panel-body { display: flex; align-items: center; gap: 10px; }
|
||||
.fx-side { position: relative; flex: 1 1 0; min-width: 0; display: grid; gap: 6px; }
|
||||
.fx-amount { width: 100%; height: 44px; padding: 0 12px; border: 1px solid var(--border-light); border-radius: 11px; background: var(--surface-deep); color: var(--text-primary); font: 600 17px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.fx-amount:focus { outline: none; border-color: var(--primary-border); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
.fx-swap { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; padding: 0; border: 1px solid var(--border-light); border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; transition: color 150ms, border-color 150ms, transform 200ms; }
|
||||
.fx-swap:hover { color: var(--primary-light); border-color: var(--primary-border); transform: rotate(180deg); }
|
||||
.fx-picker { position: relative; }
|
||||
.fx-picker-btn { display: flex; align-items: center; gap: 6px; width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; transition: border-color 150ms, color 150ms; }
|
||||
.fx-picker-btn:hover, .fx-picker-btn.is-open { border-color: var(--primary-border); color: var(--text-primary); }
|
||||
.fx-picker-btn b { flex: 0 0 auto; color: var(--text-primary); font-size: 12.5px; font-weight: 600; letter-spacing: .02em; }
|
||||
.fx-picker-btn span { flex: 1 1 auto; overflow: hidden; color: var(--text-tertiary); font-size: 11px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fx-picker-btn svg { flex: 0 0 auto; color: var(--text-tertiary); }
|
||||
.fx-picker-pop { position: absolute; top: calc(100% + 5px); z-index: 5; width: 232px; padding: 6px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
|
||||
.fx-picker.left .fx-picker-pop { left: 0; }
|
||||
.fx-picker.right .fx-picker-pop { right: 0; }
|
||||
.fx-picker-search { width: 100%; height: 30px; margin-bottom: 5px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--surface-deep); color: var(--text-primary); font-size: 11.5px; }
|
||||
.fx-picker-search:focus { outline: none; border-color: var(--primary-border); }
|
||||
.fx-picker-list { max-height: 220px; overflow-y: auto; }
|
||||
.fx-picker-item { display: flex; align-items: baseline; gap: 7px; width: 100%; padding: 6px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }
|
||||
.fx-picker-item:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.fx-picker-item.active { background: var(--primary-bg); }
|
||||
.fx-picker-item b { flex: 0 0 auto; color: var(--text-primary); font-size: 12px; font-weight: 600; }
|
||||
.fx-picker-item.active b { color: var(--primary-light); }
|
||||
.fx-picker-item span { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fx-picker-empty { padding: 12px 9px; color: var(--text-tertiary); font-size: 11px; text-align: center; }
|
||||
.fx-rate-line { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12px; }
|
||||
.fx-rate-line b { color: var(--primary-light); font-family: var(--font-mono); font-size: 13px; }
|
||||
.fx-rate-line em { margin-left: 7px; color: var(--text-tertiary); font-size: 10.5px; font-style: normal; }
|
||||
.fx-error, .fx-warn { color: var(--warning); }
|
||||
.fx-panel-foot { margin-top: 7px; color: var(--text-tertiary); font-size: 10px; }
|
||||
/* 时钟与汇率入口加进来后顶栏变宽,中等屏先收紧留白与品牌区,避免把右侧按钮挤出去 */
|
||||
@media (max-width: 1440px) {
|
||||
.global-header { gap: 16px; padding: 0 clamp(14px, 2.2vw, 34px); }
|
||||
.global-header-brand { min-width: 196px; }
|
||||
.global-search { flex: 0 1 auto; width: min(340px, 26vw); min-width: 148px; }
|
||||
.global-header-actions { gap: 7px; }
|
||||
}
|
||||
@media (max-width: 1280px) { .page-tabs { display: none; } }
|
||||
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.header-clock span { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
|
||||
@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.dashboard-date-chip { text-align: left; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } }
|
||||
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.clock-zone, .clock-caret { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
|
||||
@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } }
|
||||
|
||||
/* Requested compact navigation controls. */
|
||||
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--sb-border); border-radius: 8px; background: transparent; color: var(--sb-text); box-shadow: none; }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 时区清单(顶栏时钟用)
|
||||
*
|
||||
* 用 IANA 时区名而不是写死的 UTC 偏移量 —— 偏移量一年会变两次,
|
||||
* 伦敦夏天是 UTC+1、冬天是 UTC+0,写死必然算错。交给
|
||||
* Intl.DateTimeFormat 的 timeZone 处理,夏令时自动正确。
|
||||
*
|
||||
* zone / cities 只是给人看的标签,实际取时间只认 key。
|
||||
* 排列按 UTC 偏移由西向东,方便顺着找客户所在时区。
|
||||
*/
|
||||
export const TIMEZONES = [
|
||||
{ key: 'Pacific/Honolulu', zone: '西十区', cities: '檀香山' },
|
||||
{ key: 'America/Anchorage', zone: '西九区', cities: '安克雷奇' },
|
||||
{ key: 'America/Los_Angeles', zone: '西八区', cities: '洛杉矶,温哥华' },
|
||||
{ key: 'America/Denver', zone: '西七区', cities: '丹佛' },
|
||||
{ key: 'America/Chicago', zone: '西六区', cities: '芝加哥,墨西哥城' },
|
||||
{ key: 'America/New_York', zone: '西五区', cities: '纽约,多伦多' },
|
||||
{ key: 'America/Halifax', zone: '西四区', cities: '哈利法克斯,圣地亚哥' },
|
||||
{ key: 'America/Sao_Paulo', zone: '西三区', cities: '圣保罗,布宜诺斯艾利斯' },
|
||||
{ key: 'Atlantic/Azores', zone: '西一区', cities: '亚速尔群岛' },
|
||||
{ key: 'Europe/London', zone: '零时区', cities: '伦敦' },
|
||||
{ key: 'Europe/Paris', zone: '东一区', cities: '巴黎,法兰克福' },
|
||||
{ key: 'Europe/Athens', zone: '东二区', cities: '雅典,开罗' },
|
||||
{ key: 'Europe/Moscow', zone: '东三区', cities: '莫斯科,利雅得' },
|
||||
{ key: 'Asia/Dubai', zone: '东四区', cities: '迪拜' },
|
||||
{ key: 'Asia/Karachi', zone: '东五区', cities: '卡拉奇,伊斯兰堡' },
|
||||
{ key: 'Asia/Kolkata', zone: '东五区半', cities: '新德里,孟买' },
|
||||
{ key: 'Asia/Dhaka', zone: '东六区', cities: '达卡,阿拉木图' },
|
||||
{ key: 'Asia/Bangkok', zone: '东七区', cities: '曼谷,雅加达,河内' },
|
||||
{ key: 'Asia/Shanghai', zone: '东八区', cities: '北京,上海' },
|
||||
{ key: 'Asia/Tokyo', zone: '东九区', cities: '东京,首尔' },
|
||||
{ key: 'Australia/Sydney', zone: '东十区', cities: '悉尼,墨尔本' },
|
||||
{ key: 'Pacific/Noumea', zone: '东十一区', cities: '努美阿' },
|
||||
{ key: 'Pacific/Auckland', zone: '东十二区', cities: '奥克兰' }
|
||||
]
|
||||
|
||||
/** 默认时区:Wayne 在上海,默认看本地时间。 */
|
||||
export const DEFAULT_TIMEZONE = 'Asia/Shanghai'
|
||||
|
||||
export const TIMEZONE_STORAGE_KEY = 'happy.clockZone'
|
||||
|
||||
/** 按 IANA 时区名找条目;找不到给一个兜底条目,避免界面出现空白。 */
|
||||
export function findTimeZone(key) {
|
||||
return TIMEZONES.find(item => item.key === key) || TIMEZONES.find(item => item.key === DEFAULT_TIMEZONE)
|
||||
}
|
||||
|
||||
export function readStoredTimeZone() {
|
||||
try {
|
||||
const saved = localStorage.getItem(TIMEZONE_STORAGE_KEY)
|
||||
return TIMEZONES.some(item => item.key === saved) ? saved : DEFAULT_TIMEZONE
|
||||
} catch {
|
||||
return DEFAULT_TIMEZONE
|
||||
}
|
||||
}
|
||||
|
||||
/* Intl 的构造有开销,按时区缓存复用;界面每秒重绘一次,不缓存会白白产生垃圾。 */
|
||||
const timeFormatters = new Map()
|
||||
const dateFormatters = new Map()
|
||||
const offsetFormatters = new Map()
|
||||
|
||||
function timeFormatter(timeZone) {
|
||||
if (!timeFormatters.has(timeZone)) {
|
||||
timeFormatters.set(timeZone, new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone, hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}))
|
||||
}
|
||||
return timeFormatters.get(timeZone)
|
||||
}
|
||||
|
||||
function dateFormatter(timeZone) {
|
||||
if (!dateFormatters.has(timeZone)) {
|
||||
dateFormatters.set(timeZone, new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone, month: 'long', day: 'numeric', weekday: 'short'
|
||||
}))
|
||||
}
|
||||
return dateFormatters.get(timeZone)
|
||||
}
|
||||
|
||||
/** "09:28:57"(某些环境会给 24:00 表示午夜,统一成 00) */
|
||||
export function formatTimeIn(date, timeZone) {
|
||||
return timeFormatter(timeZone).format(date).replace(/^24/, '00')
|
||||
}
|
||||
|
||||
/** "9月10日周三" */
|
||||
export function formatDateIn(date, timeZone) {
|
||||
return dateFormatter(timeZone).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* "UTC+8" / "UTC+5:30" / "UTC"。
|
||||
* 优先用 Intl 的 longOffset(自动含夏令时),环境不支持时退回手算。
|
||||
*/
|
||||
export function formatOffsetIn(date, timeZone) {
|
||||
try {
|
||||
if (!offsetFormatters.has(timeZone)) {
|
||||
offsetFormatters.set(timeZone, new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }))
|
||||
}
|
||||
const part = offsetFormatters.get(timeZone).formatToParts(date).find(item => item.type === 'timeZoneName')
|
||||
const raw = part && part.value // "GMT+08:00" / "GMT"
|
||||
if (!raw) return ''
|
||||
if (raw === 'GMT') return 'UTC'
|
||||
const match = raw.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/)
|
||||
if (!match) return raw
|
||||
return `UTC${match[1]}${Number(match[2])}${match[3] && match[3] !== '00' ? `:${match[3]}` : ''}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user