ExchangeRateModal.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import api from '@/lib/api';
  4. import { X, Save, RefreshCw, Loader2, ArrowRight, AlertTriangle, Lock } from 'lucide-react';
  5. interface ExchangeRateModalProps {
  6. isOpen: boolean;
  7. onClose: () => void;
  8. }
  9. interface RateConfig {
  10. base: string;
  11. precision: number;
  12. rates: Record<string, number>;
  13. }
  14. // 常用货币名称映射,增强可读性
  15. const CURRENCY_NAMES: Record<string, string> = {
  16. CNY: '人民币 (Chinese Yuan)',
  17. USD: '美元 (US Dollar)',
  18. EUR: '欧元 (Euro)',
  19. GBP: '英镑 (British Pound)',
  20. HKD: '港币 (Hong Kong Dollar)',
  21. JPY: '日元 (Japanese Yen)',
  22. AUD: '澳元 (Australian Dollar)',
  23. CAD: '加元 (Canadian Dollar)',
  24. SGD: '新加坡元 (Singapore Dollar)',
  25. };
  26. export default function ExchangeRateModal({ isOpen, onClose }: ExchangeRateModalProps) {
  27. const [loading, setLoading] = useState(false);
  28. const [submitting, setSubmitting] = useState(false);
  29. // 默认配置
  30. const [config, setConfig] = useState<RateConfig>({
  31. base: 'CNY',
  32. precision: 4,
  33. rates: {}
  34. });
  35. // 获取数据
  36. const fetchRates = async () => {
  37. setLoading(true);
  38. try {
  39. const res = await api.get('/api/dynamic-configurations/key/EXCHANGE_RATES');
  40. const val = res.data.data?.config_value;
  41. // 兼容处理:后端可能返回 JSON 对象,也可能返回 JSON 字符串
  42. if (val && typeof val === 'object') {
  43. setConfig(val);
  44. } else if (typeof val === 'string') {
  45. try {
  46. setConfig(JSON.parse(val));
  47. } catch (e) {
  48. console.error("JSON Parse error", e);
  49. }
  50. }
  51. } catch (e) {
  52. console.error("Failed to fetch rates", e);
  53. // 兜底数据
  54. setConfig({
  55. base: 'CNY',
  56. precision: 4,
  57. rates: { "CNY": 1, "USD": 7.25, "EUR": 7.65, "GBP": 9.10, "HKD": 0.92, "JPY": 0.048 }
  58. });
  59. } finally {
  60. setLoading(false);
  61. }
  62. };
  63. useEffect(() => {
  64. if (isOpen) fetchRates();
  65. }, [isOpen]);
  66. // 处理输入
  67. const handleRateChange = (currency: string, value: string) => {
  68. // 允许输入过程中的空字符串,但存储时转为数字
  69. const numVal = value === '' ? 0 : parseFloat(value);
  70. setConfig(prev => ({
  71. ...prev,
  72. rates: {
  73. ...prev.rates,
  74. [currency]: isNaN(numVal) ? 0 : numVal
  75. }
  76. }));
  77. };
  78. // 提交保存
  79. const handleSubmit = async () => {
  80. // 安全校验:防止基准货币被篡改
  81. if (config.rates[config.base] !== 1) {
  82. alert(`错误:基准货币 ${config.base} 的汇率必须保持为 1。请重置后重试。`);
  83. return;
  84. }
  85. setSubmitting(true);
  86. try {
  87. // 必须将对象转为字符串,因为后端数据库字段通常是 TEXT
  88. const payload = {
  89. config_value: JSON.stringify(config),
  90. description: "汇率换算表 (管理员手动更新)"
  91. };
  92. await api.put('/api/dynamic-configurations/key/EXCHANGE_RATES', payload);
  93. alert("汇率配置已保存");
  94. onClose();
  95. } catch (e: any) {
  96. alert("保存失败: " + (e.response?.data?.message || e.message));
  97. } finally {
  98. setSubmitting(false);
  99. }
  100. };
  101. if (!isOpen) return null;
  102. return (
  103. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
  104. <div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]">
  105. {/* Header */}
  106. <div className="px-6 py-4 border-b flex justify-between items-center bg-slate-50">
  107. <div>
  108. <h3 className="font-bold text-lg text-slate-800 flex items-center gap-2">
  109. <RefreshCw size={18} className="text-blue-600"/> 汇率配置
  110. </h3>
  111. <p className="text-sm text-slate-500 mt-1">
  112. 系统基准货币:<span className="font-bold text-blue-700">{config.base}</span>
  113. </p>
  114. </div>
  115. <button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition">
  116. <X size={20} className="text-slate-500" />
  117. </button>
  118. </div>
  119. {/* ⚠️ 警告栏:明确换算逻辑 */}
  120. <div className="bg-yellow-50 px-6 py-3 border-b border-yellow-100 flex items-start gap-3">
  121. <AlertTriangle className="text-yellow-600 shrink-0 mt-0.5" size={18} />
  122. <div className="text-xs text-yellow-800 leading-relaxed">
  123. <strong>核心逻辑:</strong> 这里的数值代表 <strong>1 单位该货币</strong> 等值于 <strong>多少单位基准货币 ({config.base})</strong>。<br/>
  124. 例如:若基准是 CNY,USD 行填入 <code className="bg-yellow-200 px-1 rounded">7.25</code>,代表 <strong>1 USD = 7.25 CNY</strong>。
  125. </div>
  126. </div>
  127. {/* Body */}
  128. <div className="p-6 overflow-y-auto flex-1 bg-slate-50/50">
  129. {loading ? (
  130. <div className="flex justify-center py-12">
  131. <Loader2 className="animate-spin text-slate-400" size={32} />
  132. </div>
  133. ) : (
  134. <div className="space-y-4">
  135. {Object.entries(config.rates).map(([currency, rate]) => {
  136. const isBase = currency === config.base;
  137. // 辅助计算:反向汇率 (例如 1 CNY = 0.137 USD)
  138. // 用于给用户直觉上的校验
  139. const inverseRate = rate > 0 ? (1 / rate).toFixed(4) : '0';
  140. return (
  141. <div
  142. key={currency}
  143. className={`
  144. relative p-4 rounded-xl border transition-all
  145. ${isBase ? 'bg-slate-100 border-slate-200 opacity-80' : 'bg-white border-slate-200 hover:border-blue-300 hover:shadow-md'}
  146. `}
  147. >
  148. <div className="flex flex-col md:flex-row md:items-center gap-4">
  149. {/* 1. 左侧:货币信息 */}
  150. <div className="w-full md:w-32 shrink-0">
  151. <div className="flex items-center gap-2">
  152. <span title="基准货币锁定" className="font-bold text-lg text-slate-700">{currency}</span>
  153. {isBase && <Lock size={14} className="text-slate-400"/>}
  154. </div>
  155. <div className="text-xs text-slate-500 truncate" title={CURRENCY_NAMES[currency]}>
  156. {CURRENCY_NAMES[currency] || currency}
  157. </div>
  158. </div>
  159. {/* 2. 中间:等式输入区 (防御性设计核心) */}
  160. <div className="flex-1 flex items-center gap-3 bg-slate-50 p-2 rounded-lg border border-slate-200 shadow-inner">
  161. <div className="text-sm font-medium text-slate-500 whitespace-nowrap pl-2">
  162. 1 {currency}
  163. </div>
  164. <ArrowRight size={16} className="text-slate-400" />
  165. <div className="relative flex-1">
  166. <input
  167. type="number"
  168. step="0.0001"
  169. disabled={isBase}
  170. value={rate}
  171. onChange={(e) => handleRateChange(currency, e.target.value)}
  172. className={`
  173. w-full text-center font-mono font-bold text-lg bg-transparent outline-none
  174. ${isBase ? 'text-slate-500 cursor-not-allowed' : 'text-blue-700'}
  175. `}
  176. placeholder="0.00"
  177. />
  178. </div>
  179. <div className="text-sm font-bold text-slate-700 whitespace-nowrap pr-2">
  180. {config.base}
  181. </div>
  182. </div>
  183. {/* 3. 右侧:反向参照 (Double Check) */}
  184. {!isBase && (
  185. <div className="w-full md:w-48 shrink-0 text-right md:border-l md:pl-4 border-slate-100">
  186. <div className="text-[10px] text-slate-400 uppercase tracking-wide">
  187. 反向参考 (Inverse)
  188. </div>
  189. <div className="text-xs font-mono text-slate-600 mt-1">
  190. 1 {config.base} ≈ <span className="font-bold">{inverseRate}</span> {currency}
  191. </div>
  192. </div>
  193. )}
  194. </div>
  195. </div>
  196. );
  197. })}
  198. </div>
  199. )}
  200. </div>
  201. {/* Footer */}
  202. <div className="px-6 py-4 border-t bg-slate-50 flex justify-end gap-3">
  203. <button
  204. onClick={fetchRates}
  205. 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"
  206. disabled={submitting || loading}
  207. >
  208. <RefreshCw size={16} className={loading ? "animate-spin" : ""} /> 重置
  209. </button>
  210. <button
  211. onClick={handleSubmit}
  212. disabled={submitting || loading}
  213. 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"
  214. >
  215. {submitting ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  216. 保存配置
  217. </button>
  218. </div>
  219. </div>
  220. </div>
  221. );
  222. }