| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- 'use client';
- import { useState, useEffect } from 'react';
- import api from '@/lib/api';
- import { X, Save, RefreshCw, Loader2, ArrowRight, AlertTriangle, Lock } from 'lucide-react';
- interface ExchangeRateModalProps {
- isOpen: boolean;
- onClose: () => void;
- }
- interface RateConfig {
- base: string;
- precision: number;
- rates: Record<string, number>;
- }
- // 常用货币名称映射,增强可读性
- const CURRENCY_NAMES: Record<string, string> = {
- CNY: '人民币 (Chinese Yuan)',
- USD: '美元 (US Dollar)',
- EUR: '欧元 (Euro)',
- GBP: '英镑 (British Pound)',
- HKD: '港币 (Hong Kong Dollar)',
- JPY: '日元 (Japanese Yen)',
- AUD: '澳元 (Australian Dollar)',
- CAD: '加元 (Canadian Dollar)',
- SGD: '新加坡元 (Singapore Dollar)',
- };
- export default function ExchangeRateModal({ isOpen, onClose }: ExchangeRateModalProps) {
- const [loading, setLoading] = useState(false);
- const [submitting, setSubmitting] = useState(false);
-
- // 默认配置
- const [config, setConfig] = useState<RateConfig>({
- base: 'CNY',
- precision: 4,
- rates: {}
- });
- // 获取数据
- const fetchRates = async () => {
- setLoading(true);
- try {
- const res = await api.get('/api/dynamic-configurations/key/EXCHANGE_RATES');
- const val = res.data.data?.config_value;
-
- // 兼容处理:后端可能返回 JSON 对象,也可能返回 JSON 字符串
- if (val && typeof val === 'object') {
- setConfig(val);
- } else if (typeof val === 'string') {
- try {
- setConfig(JSON.parse(val));
- } catch (e) {
- console.error("JSON Parse error", e);
- }
- }
- } catch (e) {
- console.error("Failed to fetch rates", e);
- // 兜底数据
- setConfig({
- base: 'CNY',
- precision: 4,
- rates: { "CNY": 1, "USD": 7.25, "EUR": 7.65, "GBP": 9.10, "HKD": 0.92, "JPY": 0.048 }
- });
- } finally {
- setLoading(false);
- }
- };
- useEffect(() => {
- if (isOpen) fetchRates();
- }, [isOpen]);
- // 处理输入
- const handleRateChange = (currency: string, value: string) => {
- // 允许输入过程中的空字符串,但存储时转为数字
- const numVal = value === '' ? 0 : parseFloat(value);
-
- setConfig(prev => ({
- ...prev,
- rates: {
- ...prev.rates,
- [currency]: isNaN(numVal) ? 0 : numVal
- }
- }));
- };
- // 提交保存
- const handleSubmit = async () => {
- // 安全校验:防止基准货币被篡改
- if (config.rates[config.base] !== 1) {
- alert(`错误:基准货币 ${config.base} 的汇率必须保持为 1。请重置后重试。`);
- return;
- }
- setSubmitting(true);
- try {
- // 必须将对象转为字符串,因为后端数据库字段通常是 TEXT
- const payload = {
- config_value: JSON.stringify(config),
- description: "汇率换算表 (管理员手动更新)"
- };
-
- await api.put('/api/dynamic-configurations/key/EXCHANGE_RATES', payload);
- alert("汇率配置已保存");
- onClose();
- } catch (e: any) {
- alert("保存失败: " + (e.response?.data?.message || e.message));
- } finally {
- setSubmitting(false);
- }
- };
- if (!isOpen) return null;
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
- <div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]">
-
- {/* Header */}
- <div className="px-6 py-4 border-b flex justify-between items-center bg-slate-50">
- <div>
- <h3 className="font-bold text-lg text-slate-800 flex items-center gap-2">
- <RefreshCw size={18} className="text-blue-600"/> 汇率配置
- </h3>
- <p className="text-sm text-slate-500 mt-1">
- 系统基准货币:<span className="font-bold text-blue-700">{config.base}</span>
- </p>
- </div>
- <button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition">
- <X size={20} className="text-slate-500" />
- </button>
- </div>
- {/* ⚠️ 警告栏:明确换算逻辑 */}
- <div className="bg-yellow-50 px-6 py-3 border-b border-yellow-100 flex items-start gap-3">
- <AlertTriangle className="text-yellow-600 shrink-0 mt-0.5" size={18} />
- <div className="text-xs text-yellow-800 leading-relaxed">
- <strong>核心逻辑:</strong> 这里的数值代表 <strong>1 单位该货币</strong> 等值于 <strong>多少单位基准货币 ({config.base})</strong>。<br/>
- 例如:若基准是 CNY,USD 行填入 <code className="bg-yellow-200 px-1 rounded">7.25</code>,代表 <strong>1 USD = 7.25 CNY</strong>。
- </div>
- </div>
- {/* Body */}
- <div className="p-6 overflow-y-auto flex-1 bg-slate-50/50">
- {loading ? (
- <div className="flex justify-center py-12">
- <Loader2 className="animate-spin text-slate-400" size={32} />
- </div>
- ) : (
- <div className="space-y-4">
- {Object.entries(config.rates).map(([currency, rate]) => {
- const isBase = currency === config.base;
-
- // 辅助计算:反向汇率 (例如 1 CNY = 0.137 USD)
- // 用于给用户直觉上的校验
- const inverseRate = rate > 0 ? (1 / rate).toFixed(4) : '0';
- return (
- <div
- key={currency}
- className={`
- relative p-4 rounded-xl border transition-all
- ${isBase ? 'bg-slate-100 border-slate-200 opacity-80' : 'bg-white border-slate-200 hover:border-blue-300 hover:shadow-md'}
- `}
- >
- <div className="flex flex-col md:flex-row md:items-center gap-4">
-
- {/* 1. 左侧:货币信息 */}
- <div className="w-full md:w-32 shrink-0">
- <div className="flex items-center gap-2">
- <span title="基准货币锁定" className="font-bold text-lg text-slate-700">{currency}</span>
- {isBase && <Lock size={14} className="text-slate-400"/>}
- </div>
- <div className="text-xs text-slate-500 truncate" title={CURRENCY_NAMES[currency]}>
- {CURRENCY_NAMES[currency] || currency}
- </div>
- </div>
- {/* 2. 中间:等式输入区 (防御性设计核心) */}
- <div className="flex-1 flex items-center gap-3 bg-slate-50 p-2 rounded-lg border border-slate-200 shadow-inner">
- <div className="text-sm font-medium text-slate-500 whitespace-nowrap pl-2">
- 1 {currency}
- </div>
-
- <ArrowRight size={16} className="text-slate-400" />
-
- <div className="relative flex-1">
- <input
- type="number"
- step="0.0001"
- disabled={isBase}
- value={rate}
- onChange={(e) => handleRateChange(currency, e.target.value)}
- className={`
- w-full text-center font-mono font-bold text-lg bg-transparent outline-none
- ${isBase ? 'text-slate-500 cursor-not-allowed' : 'text-blue-700'}
- `}
- placeholder="0.00"
- />
- </div>
- <div className="text-sm font-bold text-slate-700 whitespace-nowrap pr-2">
- {config.base}
- </div>
- </div>
- {/* 3. 右侧:反向参照 (Double Check) */}
- {!isBase && (
- <div className="w-full md:w-48 shrink-0 text-right md:border-l md:pl-4 border-slate-100">
- <div className="text-[10px] text-slate-400 uppercase tracking-wide">
- 反向参考 (Inverse)
- </div>
- <div className="text-xs font-mono text-slate-600 mt-1">
- 1 {config.base} ≈ <span className="font-bold">{inverseRate}</span> {currency}
- </div>
- </div>
- )}
- </div>
- </div>
- );
- })}
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="px-6 py-4 border-t bg-slate-50 flex justify-end gap-3">
- <button
- onClick={fetchRates}
- className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-white border border-transparent hover:border-slate-200 rounded-lg transition"
- disabled={submitting || loading}
- >
- <RefreshCw size={16} className={loading ? "animate-spin" : ""} /> 重置
- </button>
- <button
- onClick={handleSubmit}
- disabled={submitting || loading}
- className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm transition disabled:opacity-70 disabled:cursor-not-allowed"
- >
- {submitting ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
- 保存配置
- </button>
- </div>
- </div>
- </div>
- );
- }
|