Files
happy-export/frontend/src/components/FxCalculator.jsx
T
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

331 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
import CurrencyFlag from './CurrencyFlag'
import {
buildPrimaryCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
} from '../currencies'
import { amountToWords } from '../amountInWords'
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
const raw = keyword.trim()
return options.filter(item =>
item.code.includes(term)
|| (item.name && item.name.includes(raw))
|| (item.region && item.region.includes(raw))
)
}, [options, keyword])
const labelOf = item => `${item.code} ${item.name}${item.region ? ` · ${item.region}` : ''}`
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}
>
<CurrencyFlag code={value} width={20} />
<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}
title={labelOf(item)}
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
onClick={() => { onChange(item.code); setOpen(false) }}
>
<CurrencyFlag code={item.code} width={20} />
<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 [copied, setCopied] = useState(false)
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])
/* 只给主流 13 种币种(见 currencies.js 的 PRIMARY_CURRENCIES),不跟随接口的全量列表 */
const options = useMemo(() => buildPrimaryCurrencyOptions(), [])
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' })
: ''
/* 英文大写金额跟随左侧(from)的金额与币种 —— 单证上写的就是报价币种那一侧 */
const words = useMemo(() => {
const value = parseAmount(fromDisplay)
return value === null ? '' : amountToWords(value, from)
}, [fromDisplay, from])
const copyWords = async () => {
if (!words) return
try {
await navigator.clipboard.writeText(words)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
/* 剪贴板不可用(非安全上下文等)时不打断使用,手动选中也能复制 */
}
}
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-words">
<div className="fx-words-head">
<span className="fx-words-label">英文大写金额</span>
{words && (
<button type="button" className="fx-copy" onClick={copyWords}>
{copied ? '已复制' : '复制'}
</button>
)}
</div>
<p className={`fx-words-text${words ? '' : ' is-empty'}`}>
{words || '在左侧输入金额后自动生成'}
</p>
</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