diff --git a/src/components/tool/currency.tsx b/src/components/tool/currency.tsx new file mode 100644 index 0000000..5e60566 --- /dev/null +++ b/src/components/tool/currency.tsx @@ -0,0 +1,451 @@ +import { useState, useEffect, type FC } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { toast } from "sonner"; +import { Check, X, Plus } from "lucide-react"; + +interface Currency { + code: string; + name: string; + symbol: string; +} + +// 常用货币列表 (仅包含 Frankfurter API 支持的货币) +const AVAILABLE_CURRENCIES: Currency[] = [ + { code: "USD", name: "美元", symbol: "$" }, + { code: "CNY", name: "人民币", symbol: "¥" }, + { code: "EUR", name: "欧元", symbol: "€" }, + { code: "GBP", name: "英镑", symbol: "£" }, + { code: "JPY", name: "日元", symbol: "¥" }, + { code: "HKD", name: "港币", symbol: "HK$" }, + { code: "AUD", name: "澳元", symbol: "A$" }, + { code: "CAD", name: "加元", symbol: "C$" }, + { code: "SGD", name: "新加坡元", symbol: "S$" }, + { code: "CHF", name: "瑞士法郎", symbol: "CHF" }, + { code: "NZD", name: "新西兰元", symbol: "NZ$" }, + { code: "KRW", name: "韩元", symbol: "₩" }, + { code: "THB", name: "泰铢", symbol: "฿" }, + { code: "MYR", name: "马来西亚林吉特", symbol: "RM" }, + { code: "INR", name: "印度卢比", symbol: "₹" }, + { code: "BRL", name: "巴西雷亚尔", symbol: "R$" }, + { code: "MXN", name: "墨西哥比索", symbol: "MX$" }, + { code: "SEK", name: "瑞典克朗", symbol: "kr" }, + { code: "NOK", name: "挪威克朗", symbol: "kr" }, + { code: "DKK", name: "丹麦克朗", symbol: "kr" }, + { code: "PLN", name: "波兰兹罗提", symbol: "zł" }, + { code: "TRY", name: "土耳其里拉", symbol: "₺" }, + { code: "PHP", name: "菲律宾比索", symbol: "₱" }, + { code: "IDR", name: "印尼盾", symbol: "Rp" }, + { code: "ILS", name: "以色列新谢克尔", symbol: "₪" }, + { code: "CZK", name: "捷克克朗", symbol: "Kč" }, + { code: "RON", name: "罗马尼亚列伊", symbol: "lei" }, + { code: "HUF", name: "匈牙利福林", symbol: "Ft" }, + { code: "BGN", name: "保加利亚列弗", symbol: "лв" }, + { code: "ISK", name: "冰岛克朗", symbol: "kr" }, +]; + +// 必选货币(不可删除) - 美元第一,人民币第二 +const REQUIRED_CURRENCIES = ["USD", "CNY"]; + +const STORAGE_KEY = "selectedCurrencies"; +const RATES_CACHE_KEY = "currencyRatesCache"; +const RATES_CACHE_DURATION = 60 * 60 * 1000; // 1小时(毫秒) + +const Tool: FC = () => { + const [amounts, setAmounts] = useState>({}); + const [selectedCurrencies, setSelectedCurrencies] = useState([]); + const [rates, setRates] = useState>({}); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + // 从 localStorage 加载已选货币 + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length >= 2) { + setSelectedCurrencies(parsed); + return; + } + } + } catch (error) { + console.error("Failed to load saved currencies:", error); + } + // 默认选择 USD 和 CNY (美元第一) + setSelectedCurrencies(["USD", "CNY"]); + }, []); + + // 保存已选货币到 localStorage + useEffect(() => { + if (selectedCurrencies.length >= 2) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selectedCurrencies)); + } catch (error) { + console.error("Failed to save currencies:", error); + } + } + }, [selectedCurrencies]); + + // 获取汇率数据 - 使用 stale-while-revalidate 缓存策略 + useEffect(() => { + const fetchRates = async (useCache = true) => { + try { + // 1. 尝试从缓存加载 + if (useCache) { + const cached = localStorage.getItem(RATES_CACHE_KEY); + if (cached) { + try { + const { rates: cachedRates, timestamp } = JSON.parse(cached); + const age = Date.now() - timestamp; + + if (age < RATES_CACHE_DURATION) { + // 缓存未过期,直接使用 + setRates(cachedRates); + setLoading(false); + return; + } else { + // 缓存过期,先使用旧数据,后台更新 + setRates(cachedRates); + setLoading(false); + // 继续执行下面的网络请求 + } + } catch (e) { + console.error("Failed to parse cached rates:", e); + } + } + } + + // 2. 从网络获取最新数据 + setLoading(true); + const response = await fetch("https://api.frankfurter.app/latest?base=USD", { + cache: "no-cache", // 绕过浏览器缓存,确保获取最新数据 + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const allRates: Record = { USD: 1, ...data.rates }; + + // 3. 更新状态和缓存 + setRates(allRates); + localStorage.setItem( + RATES_CACHE_KEY, + JSON.stringify({ rates: allRates, timestamp: Date.now() }) + ); + } catch (error) { + // 如果有缓存,即使网络请求失败也继续使用缓存 + const cached = localStorage.getItem(RATES_CACHE_KEY); + if (cached) { + try { + const { rates: cachedRates } = JSON.parse(cached); + setRates(cachedRates); + toast.info("使用缓存的汇率数据(网络请求失败)"); + } catch (e) { + console.error("Failed to use cached rates:", e); + if (error instanceof Error) { + toast.error(`获取汇率失败: ${error.message}`); + } else { + toast.error("获取汇率失败"); + } + } + } else { + if (error instanceof Error) { + toast.error(`获取汇率失败: ${error.message}`); + } else { + toast.error("获取汇率失败"); + } + } + } finally { + setLoading(false); + } + }; + + fetchRates(); + }, []); + + // 初始化金额并在汇率加载后计算其他货币 + useEffect(() => { + if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { + if (Object.keys(amounts).length === 0) { + // 首次初始化,以 1 USD 为基准 + const initialAmounts: Record = {}; + const baseAmountInUSD = 1; // 1美元 + + selectedCurrencies.forEach(code => { + if (rates[code]) { + const convertedAmount = baseAmountInUSD * rates[code]; + initialAmounts[code] = convertedAmount.toFixed(2); + } else { + initialAmounts[code] = ""; + } + }); + setAmounts(initialAmounts); + } + } + }, [selectedCurrencies, rates, amounts]); + + // 监听汇率更新,自动重新计算所有金额 + useEffect(() => { + if (Object.keys(rates).length > 0 && Object.keys(amounts).length > 0) { + // 找到第一个有值的货币作为基准 + const baseCurrency = selectedCurrencies.find(code => amounts[code] && parseFloat(amounts[code]) > 0); + + if (baseCurrency && rates[baseCurrency]) { + const baseValue = parseFloat(amounts[baseCurrency]); + const amountInUSD = baseValue / rates[baseCurrency]; + + // 重新计算所有货币 + const updatedAmounts: Record = {}; + selectedCurrencies.forEach(code => { + if (rates[code]) { + const convertedAmount = amountInUSD * rates[code]; + updatedAmounts[code] = convertedAmount.toFixed(2); + } else { + updatedAmounts[code] = amounts[code] || ""; + } + }); + + // 只在值有变化时更新 + const hasChanged = selectedCurrencies.some( + code => updatedAmounts[code] !== amounts[code] + ); + if (hasChanged) { + setAmounts(updatedAmounts); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rates]); + + // 处理货币金额变化 + const handleAmountChange = (currencyCode: string, value: string) => { + // 只允许数字和小数点 + if (value !== "" && !/^\d*\.?\d*$/.test(value)) { + return; + } + + if (!rates[currencyCode]) { + setAmounts({ ...amounts, [currencyCode]: value }); + return; + } + + // 如果输入为空,清空所有货币 + if (value === "" || value === "0" || parseFloat(value) === 0) { + const newAmounts: Record = {}; + selectedCurrencies.forEach(code => { + newAmounts[code] = ""; + }); + setAmounts(newAmounts); + return; + } + + const inputValue = parseFloat(value); + + // 计算其他货币的值 + const newAmounts: Record = { [currencyCode]: value }; + + // 先转换为 USD + const amountInUSD = inputValue / rates[currencyCode]; + + // 转换为其他货币 + selectedCurrencies.forEach(code => { + if (code !== currencyCode && rates[code]) { + const convertedAmount = amountInUSD * rates[code]; + newAmounts[code] = convertedAmount.toFixed(2); + } + }); + + setAmounts(newAmounts); + }; + + // 添加货币 + const addCurrency = (currencyCode: string) => { + if (!selectedCurrencies.includes(currencyCode)) { + setSelectedCurrencies([...selectedCurrencies, currencyCode]); + + // 计算新货币的初始金额 + if (rates[currencyCode] && Object.keys(amounts).length > 0) { + const firstCurrency = selectedCurrencies[0]; + if (firstCurrency && amounts[firstCurrency] && rates[firstCurrency]) { + const firstAmount = parseFloat(amounts[firstCurrency]) || 0; + const amountInUSD = firstAmount / rates[firstCurrency]; + const newAmount = amountInUSD * rates[currencyCode]; + setAmounts({ ...amounts, [currencyCode]: newAmount.toFixed(2) }); + } + } + + toast.success(`已添加 ${currencyCode}`); + } + }; + + // 删除货币 + const removeCurrency = (currencyCode: string) => { + if (REQUIRED_CURRENCIES.includes(currencyCode)) { + toast.error(`${currencyCode} 是必选货币,无法删除`); + return; + } + if (selectedCurrencies.length <= 2) { + toast.error("至少需要保留两种货币"); + return; + } + setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode)); + + // 删除对应的金额 + const newAmounts = { ...amounts }; + delete newAmounts[currencyCode]; + setAmounts(newAmounts); + + toast.success(`已删除 ${currencyCode}`); + }; + + // 获取货币信息 + const getCurrency = (code: string): Currency | undefined => { + return AVAILABLE_CURRENCIES.find((c) => c.code === code); + }; + + return ( +
+ + {/* 货币选择器 */} +
+
+ + {/* 添加货币按钮 - 固定在右上角 */} + + + + + + + + + 未找到货币 + + {AVAILABLE_CURRENCIES.map((currency) => { + const isSelected = selectedCurrencies.includes(currency.code); + const isRequired = REQUIRED_CURRENCIES.includes(currency.code); + return ( + { + if (isRequired) return; // 必选货币不可操作 + if (isSelected && selectedCurrencies.length > 2) { + removeCurrency(currency.code); + } else if (!isSelected) { + addCurrency(currency.code); + } + }} + > + + + {currency.symbol} {currency.code} - {currency.name} + + + ); + })} + + + + + +
+ + {/* 已选货币 Badges */} +
+ {selectedCurrencies.map((code) => { + const currency = getCurrency(code); + const isRequired = REQUIRED_CURRENCIES.includes(code); + return ( + + {currency?.symbol} {code} + {!isRequired && ( + + )} + + ); + })} +
+ + + {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join("、")} 为必选货币,至少保留两种货币 + +
+ + {/* 货币列表 */} + {loading ? ( +
+ 加载汇率数据中... +
+ ) : ( +
+
货币金额:
+
+ {selectedCurrencies.map((code) => { + const currency = getCurrency(code); + if (!currency) return null; + + return ( + + + + {currency.symbol} {currency.code} - {currency.name} + + + + handleAmountChange(code, e.target.value)} + className="text-xl font-semibold" + /> + + + ); + })} +
+
+ )} + + {!loading && Object.keys(rates).length > 0 && ( +
+ 汇率数据来源: Frankfurter API (基于欧洲央行数据) +
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/tool/index.tsx b/src/components/tool/index.tsx index 58db165..794f72f 100644 --- a/src/components/tool/index.tsx +++ b/src/components/tool/index.tsx @@ -1,10 +1,11 @@ import { lazy, type ReactNode, type ComponentType } from 'react'; -import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react' +import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin, Coins } from 'lucide-react' // 懒加载工具组件 const UUID = lazy(() => import('./uuid')) const JSON = lazy(() => import('./json')) const Base64 = lazy(() => import('./base64')) +const Currency = lazy(() => import('./currency')) const DNS = lazy(() => import('./network/dns')) const Ping = lazy(() => import('./network/ping')) const TCPing = lazy(() => import('./network/tcping')) @@ -42,6 +43,13 @@ export const tools: Tool[] = [ icon: , component: Base64, }, + { + path: "currency", + name: "Currency Converter", + description: "Real-time currency exchange rates", + icon: , + component: Currency, + }, { path: "network", name: "Network Tools",