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
+3 -3
View File
@@ -12,9 +12,9 @@ build/
!.env.example !.env.example
# ---------- 数据库与运行时数据 ---------- # ---------- 数据库与运行时数据 ----------
backend/data/*.db # backend/data/ 放的是运行时产物(trade.db、汇率缓存 fx-cache.json 等),
backend/data/*.db-* # 全是本机生成的数据,一律不入库;新环境部署时由服务自动创建。
backend/data/*.sqlite* backend/data/
backend/uploads/ backend/uploads/
# ---------- 日志 ---------- # ---------- 日志 ----------
+202
View File
@@ -0,0 +1,202 @@
/**
* happy 出口通 —— 汇率取数
*
* 目标:给顶栏的汇率计算器提供**任意两种货币之间**的换算汇率。
*
* 设计要点:
* 1. 所有汇率统一以 USD 为基准存取(rates[X] = 1 USD 值多少 X),
* 任意币种对 (from → to) 用 rates[to] / rates[from] 即可算出交叉汇率。
* 2. 两个公开数据源按顺序尝试,任一成功即用:
* - open.er-api.com 免费无需 key,约 160+ 币种,带更新日期
* - frankfurter.app 欧洲央行数据,约 30 币种,权威但覆盖面小
* 3. 结果写内存 + 落盘(backend/data/fx-cache.json,该目录已 gitignore)。
* 重启后仍能立刻给出上次的汇率,不必等网络。
* 4. 网络彻底不可用时回退内置离线汇率表,并在响应里标注 source='offline',
* 让前端能明确提示「这是离线参考值」。宁可给一个标清楚来源的估算,
* 也不要让计算器变成一块空白。
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', 'data');
const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
const TTL_MS = 60 * 60 * 1000;
/** 单个数据源的超时。 */
const TIMEOUT_MS = 8000;
const USER_AGENT = 'happy-export-fx';
/* ------------------------------ 数据源 ------------------------------ */
/**
* 每个数据源负责把自家返回体归一成 { rates, updatedAt }。
* rates 必须以 USD 为基准,键为大写三位币种码。
*/
const PROVIDERS = [
{
id: 'open.er-api.com',
label: 'ExchangeRate-API',
url: 'https://open.er-api.com/v6/latest/USD',
pick(json) {
if (!json || json.result === 'error' || !json.rates) return null;
const unix = Number(json.time_last_update_unix);
return {
rates: json.rates,
// 这个源会给到精确到秒的更新时间;没有就退回 next_update 或当前时间
updatedAt: Number.isFinite(unix) && unix > 0
? new Date(unix * 1000).toISOString()
: new Date().toISOString()
};
}
},
{
id: 'frankfurter.app',
label: 'Frankfurter(欧洲央行)',
url: 'https://api.frankfurter.app/latest?from=USD',
pick(json) {
if (!json || !json.rates) return null;
return {
// 这个源不返回基准货币自身,补上 USD: 1
rates: { USD: 1, ...json.rates },
updatedAt: json.date ? new Date(`${json.date}T00:00:00Z`).toISOString() : new Date().toISOString()
};
}
}
];
/**
* 离线兜底汇率(1 USD 值多少目标币)。
* 仅在两个数据源都拿不到、且本地也没有任何历史缓存时启用。
* 数值是写这份代码时的市场中间价量级,只适合粗估,响应里会明确标注。
*/
const OFFLINE_RATES = {
USD: 1,
CNY: 7.10, HKD: 7.80, TWD: 32.2, JPY: 152, KRW: 1360, SGD: 1.34,
EUR: 0.92, GBP: 0.78, CHF: 0.88, SEK: 10.5, NOK: 10.7, DKK: 6.88,
PLN: 3.95, CZK: 23.0, TRY: 34.0, RUB: 92.0,
AUD: 1.51, NZD: 1.64, CAD: 1.37, MXN: 18.5, BRL: 5.55, ARS: 950, CLP: 930,
INR: 83.5, PKR: 278, BDT: 118, LKR: 300, NPR: 134, VND: 24800,
THB: 34.5, MYR: 4.45, IDR: 15600, PHP: 56.5, KHR: 4100, MMK: 2100,
AED: 3.6725, SAR: 3.75, QAR: 3.64, KWD: 0.307, OMR: 0.385, ILS: 3.72,
EGP: 48.5, ZAR: 18.2, NGN: 1600, KES: 129, MAD: 9.85, TZS: 2700,
UAH: 41.0, RON: 4.58, HUF: 360, BGN: 1.80, ISK: 138, GEL: 2.72
};
/* ------------------------------ 工具 ------------------------------ */
/** 只保留「键是 3 位字母、值是正数」的条目,顺手统一成大写。 */
function sanitizeRates(raw) {
const out = {};
if (!raw || typeof raw !== 'object') return out;
for (const [key, value] of Object.entries(raw)) {
const code = String(key).trim().toUpperCase();
const num = Number(value);
if (/^[A-Z]{3}$/.test(code) && Number.isFinite(num) && num > 0) out[code] = num;
}
out.USD = 1;
return out;
}
function readDiskCache() {
try {
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
const rates = sanitizeRates(parsed && parsed.rates);
if (Object.keys(rates).length > 1) {
return {
rates,
updatedAt: parsed.updatedAt || null,
fetchedAt: Number(parsed.fetchedAt) || 0,
source: parsed.source || 'cache'
};
}
} catch {
/* 没有缓存或文件损坏都走正常取数流程 */
}
return null;
}
function writeDiskCache(payload) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8');
} catch {
/* 写不进去不影响本次换算,只是下次重启享受不到缓存 */
}
}
/** 带超时的 GET,返回解析后的 JSON 对象或 null。 */
async function fetchJson(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/* ------------------------------ 主流程 ------------------------------ */
let memory = readDiskCache();
/** 同一个进程里并发调用只发一次网络请求。 */
let inflight = null;
async function refresh() {
for (const provider of PROVIDERS) {
const json = await fetchJson(provider.url);
const picked = json && provider.pick(json);
if (!picked) continue;
const rates = sanitizeRates(picked.rates);
if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源
const payload = {
rates,
updatedAt: picked.updatedAt,
fetchedAt: Date.now(),
source: provider.id,
sourceLabel: provider.label
};
memory = payload;
writeDiskCache(payload);
return payload;
}
return null;
}
/**
* 取汇率表。永远不抛异常。
* @param {boolean} force 为 true 时忽略缓存有效期,强制刷新
*/
async function getRates(force = false) {
const fresh = memory && Date.now() - memory.fetchedAt < TTL_MS;
if (fresh && !force) return { ...memory, stale: false, cached: true };
if (!inflight) {
inflight = refresh().finally(() => { inflight = null; });
}
const refreshed = await inflight;
if (refreshed) return { ...refreshed, stale: false, cached: false };
// 网络失败:有旧数据就先用旧数据,但要标出来
if (memory) return { ...memory, stale: true, cached: true, warning: '汇率刷新失败,显示的是上次成功获取的数据' };
return {
rates: OFFLINE_RATES,
updatedAt: null,
fetchedAt: Date.now(),
source: 'offline',
sourceLabel: '内置离线汇率',
stale: true,
cached: false,
warning: '当前无法连接汇率服务,显示的是内置离线参考值'
};
}
module.exports = { getRates, PROVIDERS, OFFLINE_RATES };
+31
View File
@@ -0,0 +1,31 @@
/**
* 汇率接口。
* 挂载点:/api/fx
*/
const express = require('express');
const fx = require('../lib/fx');
const router = express.Router();
/**
* GET /api/fx/rates?force=1
*
* 返回以 USD 为基准的完整汇率表:
* { ok, base: 'USD', rates: { USD:1, CNY:7.1, ... }, updatedAt, source, sourceLabel,
* stale, cached, warning? }
*
* 前端拿到后自行做交叉汇率:amount * rates[to] / rates[from]。
* 一次请求覆盖所有币种,切货币不再发请求。
*/
router.get('/rates', async (req, res) => {
try {
const force = String(req.query.force || '') === '1';
const data = await fx.getRates(force);
res.json({ ok: true, base: 'USD', count: Object.keys(data.rates).length, ...data });
} catch (error) {
// getRates 内部已兜底,走到这里属于意料之外
res.status(500).json({ ok: false, error: error.message });
}
});
module.exports = router;
+4
View File
@@ -5,6 +5,7 @@ const http = require('http');
const db = require('./db/db'); const db = require('./db/db');
const updater = require('./lib/updater'); const updater = require('./lib/updater');
const systemRoutes = require('./routes/system'); const systemRoutes = require('./routes/system');
const fxRoutes = require('./routes/fx');
const app = express(); const app = express();
const PORT = process.env.PORT || 4300; const PORT = process.env.PORT || 4300;
@@ -81,6 +82,9 @@ app.get('/api/health', (req, res) => {
// 系统信息与版本更新检查 // 系统信息与版本更新检查
app.use('/api/system', systemRoutes); app.use('/api/system', systemRoutes);
// 汇率(顶栏汇率计算器用)
app.use('/api/fx', fxRoutes);
// 获取工作台数据 // 获取工作台数据
app.get('/api/dashboard', (req, res) => { app.get('/api/dashboard', (req, res) => {
try { try {
+285
View File
@@ -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
+5 -5
View File
@@ -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 { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios' 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' import { HOME_PATH, PAGE_LABELS } from '../navigation'
const TABS_STORAGE_KEY = 'happy.openTabs' const TABS_STORAGE_KEY = 'happy.openTabs'
@@ -86,8 +88,6 @@ function GlobalHeader({ onOpenAssistant }) {
return () => window.removeEventListener('keydown', onKeyDown) 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) } const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
return ( return (
@@ -116,7 +116,7 @@ function GlobalHeader({ onOpenAssistant }) {
) )
})} })}
</nav> </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> </header>
) )
} }
+19
View File
@@ -209,6 +209,25 @@ export const ChevronDownIcon = ({ size = 16, className = '' }) => (
</svg> </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 = '' }) => ( 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"> <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">
+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
+110
View File
@@ -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
}
+1 -1
View File
@@ -97,7 +97,7 @@ function Dashboard() {
return ( return (
<div className="dashboard-container"> <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} /> <CalendarBoard todos={todos} />
+72 -8
View File
@@ -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); } .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 { 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: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 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); } .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 的顶部标签条) ---- */ /* ---- 已打开页面标签:搜索框后的快速跳转(参考 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 { color: var(--text-secondary); }
.page-tab.active .page-tab-close:hover { color: var(--danger); } .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; } .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 { position: relative; flex: 0 0 auto; }
.header-clock b { color: var(--text-primary); font-size: 13px; font-weight: 500; } .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, .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); } .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); } .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 { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
.dashboard-welcome .page-title { margin: 0; } .dashboard-welcome .page-title { margin: 0; }
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 13px; } .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 { 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 { 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; } .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-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-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; } .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: 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: 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; }.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: 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. */ /* 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; } .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; }
+108
View File
@@ -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 ''
}
}