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,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