refactor: update currency component for improved clarity and functionality

- Translated currency names and comments from Chinese to English for better accessibility.
- Enhanced caching logic for exchange rates, ensuring data validity based on the date.
- Improved user feedback messages for currency addition and removal actions.
- Updated UI elements to reflect changes in language and functionality, including placeholders and labels.
This commit is contained in:
typist
2025-10-30 09:07:37 +08:00
parent 8a565cf05f
commit 35287da53a

View File

@@ -15,46 +15,45 @@ interface Currency {
symbol: string; symbol: string;
} }
// 常用货币列表 (仅包含 Frankfurter API 支持的货币) // Available currencies (supported by Frankfurter API)
const AVAILABLE_CURRENCIES: Currency[] = [ const AVAILABLE_CURRENCIES: Currency[] = [
{ code: "USD", name: "美元", symbol: "$" }, { code: "USD", name: "US Dollar", symbol: "$" },
{ code: "CNY", name: "人民币", symbol: "¥" }, { code: "CNY", name: "Chinese Yuan", symbol: "¥" },
{ code: "EUR", name: "欧元", symbol: "€" }, { code: "EUR", name: "Euro", symbol: "€" },
{ code: "GBP", name: "英镑", symbol: "£" }, { code: "GBP", name: "British Pound", symbol: "£" },
{ code: "JPY", name: "日元", symbol: "¥" }, { code: "JPY", name: "Japanese Yen", symbol: "¥" },
{ code: "HKD", name: "港币", symbol: "HK$" }, { code: "HKD", name: "Hong Kong Dollar", symbol: "HK$" },
{ code: "AUD", name: "澳元", symbol: "A$" }, { code: "AUD", name: "Australian Dollar", symbol: "A$" },
{ code: "CAD", name: "加元", symbol: "C$" }, { code: "CAD", name: "Canadian Dollar", symbol: "C$" },
{ code: "SGD", name: "新加坡元", symbol: "S$" }, { code: "SGD", name: "Singapore Dollar", symbol: "S$" },
{ code: "CHF", name: "瑞士法郎", symbol: "CHF" }, { code: "CHF", name: "Swiss Franc", symbol: "CHF" },
{ code: "NZD", name: "新西兰元", symbol: "NZ$" }, { code: "NZD", name: "New Zealand Dollar", symbol: "NZ$" },
{ code: "KRW", name: "韩元", symbol: "₩" }, { code: "KRW", name: "South Korean Won", symbol: "₩" },
{ code: "THB", name: "泰铢", symbol: "฿" }, { code: "THB", name: "Thai Baht", symbol: "฿" },
{ code: "MYR", name: "马来西亚林吉特", symbol: "RM" }, { code: "MYR", name: "Malaysian Ringgit", symbol: "RM" },
{ code: "INR", name: "印度卢比", symbol: "₹" }, { code: "INR", name: "Indian Rupee", symbol: "₹" },
{ code: "BRL", name: "巴西雷亚尔", symbol: "R$" }, { code: "BRL", name: "Brazilian Real", symbol: "R$" },
{ code: "MXN", name: "墨西哥比索", symbol: "MX$" }, { code: "MXN", name: "Mexican Peso", symbol: "MX$" },
{ code: "SEK", name: "瑞典克朗", symbol: "kr" }, { code: "SEK", name: "Swedish Krona", symbol: "kr" },
{ code: "NOK", name: "挪威克朗", symbol: "kr" }, { code: "NOK", name: "Norwegian Krone", symbol: "kr" },
{ code: "DKK", name: "丹麦克朗", symbol: "kr" }, { code: "DKK", name: "Danish Krone", symbol: "kr" },
{ code: "PLN", name: "波兰兹罗提", symbol: "zł" }, { code: "PLN", name: "Polish Złoty", symbol: "zł" },
{ code: "TRY", name: "土耳其里拉", symbol: "₺" }, { code: "TRY", name: "Turkish Lira", symbol: "₺" },
{ code: "PHP", name: "菲律宾比索", symbol: "₱" }, { code: "PHP", name: "Philippine Peso", symbol: "₱" },
{ code: "IDR", name: "印尼盾", symbol: "Rp" }, { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp" },
{ code: "ILS", name: "以色列新谢克尔", symbol: "₪" }, { code: "ILS", name: "Israeli New Shekel", symbol: "₪" },
{ code: "CZK", name: "捷克克朗", symbol: "Kč" }, { code: "CZK", name: "Czech Koruna", symbol: "Kč" },
{ code: "RON", name: "罗马尼亚列伊", symbol: "lei" }, { code: "RON", name: "Romanian Leu", symbol: "lei" },
{ code: "HUF", name: "匈牙利福林", symbol: "Ft" }, { code: "HUF", name: "Hungarian Forint", symbol: "Ft" },
{ code: "BGN", name: "保加利亚列弗", symbol: "лв" }, { code: "BGN", name: "Bulgarian Lev", symbol: "лв" },
{ code: "ISK", name: "冰岛克朗", symbol: "kr" }, { code: "ISK", name: "Icelandic Króna", symbol: "kr" },
]; ];
// 必选货币(不可删除) - 美元第一,人民币第二 // Required currencies (cannot be removed) - USD first, CNY second
const REQUIRED_CURRENCIES = ["USD", "CNY"]; const REQUIRED_CURRENCIES = ["USD", "CNY"];
const STORAGE_KEY = "selectedCurrencies"; const STORAGE_KEY = "selectedCurrencies";
const RATES_CACHE_KEY = "currencyRatesCache"; const RATES_CACHE_KEY = "currencyRatesCache";
const RATES_CACHE_DURATION = 60 * 60 * 1000; // 1小时(毫秒)
const Tool: FC = () => { const Tool: FC = () => {
const [amounts, setAmounts] = useState<Record<string, string>>({}); const [amounts, setAmounts] = useState<Record<string, string>>({});
@@ -63,7 +62,7 @@ const Tool: FC = () => {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// localStorage 加载已选货币 // Load selected currencies from localStorage
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
@@ -77,11 +76,11 @@ const Tool: FC = () => {
} catch (error) { } catch (error) {
console.error("Failed to load saved currencies:", error); console.error("Failed to load saved currencies:", error);
} }
// 默认选择 USD CNY (美元第一) // Default: USD and CNY (USD first)
setSelectedCurrencies(["USD", "CNY"]); setSelectedCurrencies(["USD", "CNY"]);
}, []); }, []);
// 保存已选货币到 localStorage // Save selected currencies to localStorage
useEffect(() => { useEffect(() => {
if (selectedCurrencies.length >= 2) { if (selectedCurrencies.length >= 2) {
try { try {
@@ -92,39 +91,39 @@ const Tool: FC = () => {
} }
}, [selectedCurrencies]); }, [selectedCurrencies]);
// 获取汇率数据 - 使用 stale-while-revalidate 缓存策略 // Fetch exchange rates with date-based caching
useEffect(() => { useEffect(() => {
const fetchRates = async (useCache = true) => { const fetchRates = async () => {
try { try {
// 1. 尝试从缓存加载 // 1. Try to load from cache
if (useCache) { const cached = localStorage.getItem(RATES_CACHE_KEY);
const cached = localStorage.getItem(RATES_CACHE_KEY); if (cached) {
if (cached) { try {
try { const { rates: cachedRates, date: cachedDate } = JSON.parse(cached);
const { rates: cachedRates, timestamp } = JSON.parse(cached);
const age = Date.now() - timestamp;
if (age < RATES_CACHE_DURATION) { // Check if cached data is from today
// 缓存未过期,直接使用 const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
setRates(cachedRates);
setLoading(false); if (cachedDate === today) {
return; // Cache is valid (same date), use it directly
} else { setRates(cachedRates);
// 缓存过期,先使用旧数据,后台更新 setLoading(false);
setRates(cachedRates); return;
setLoading(false); } else {
// 继续执行下面的网络请求 // Cache is outdated, show old data first then update in background
} setRates(cachedRates);
} catch (e) { setLoading(false);
console.error("Failed to parse cached rates:", e); // Continue to fetch new data below
} }
} catch (e) {
console.error("Failed to parse cached rates:", e);
} }
} }
// 2. 从网络获取最新数据 // 2. Fetch latest data from network
setLoading(true); setLoading(true);
const response = await fetch("https://api.frankfurter.app/latest?base=USD", { const response = await fetch("https://api.frankfurter.app/latest?base=USD", {
cache: "no-cache", // 绕过浏览器缓存,确保获取最新数据 cache: "no-cache",
}); });
if (!response.ok) { if (!response.ok) {
@@ -133,34 +132,35 @@ const Tool: FC = () => {
const data = await response.json(); const data = await response.json();
const allRates: Record<string, number> = { USD: 1, ...data.rates }; const allRates: Record<string, number> = { USD: 1, ...data.rates };
const apiDate = data.date; // Date from API (YYYY-MM-DD format)
// 3. 更新状态和缓存 // 3. Update state and cache
setRates(allRates); setRates(allRates);
localStorage.setItem( localStorage.setItem(
RATES_CACHE_KEY, RATES_CACHE_KEY,
JSON.stringify({ rates: allRates, timestamp: Date.now() }) JSON.stringify({ rates: allRates, date: apiDate })
); );
} catch (error) { } catch (error) {
// 如果有缓存,即使网络请求失败也继续使用缓存 // If cache exists, continue using it even if network fails
const cached = localStorage.getItem(RATES_CACHE_KEY); const cached = localStorage.getItem(RATES_CACHE_KEY);
if (cached) { if (cached) {
try { try {
const { rates: cachedRates } = JSON.parse(cached); const { rates: cachedRates } = JSON.parse(cached);
setRates(cachedRates); setRates(cachedRates);
toast.info("使用缓存的汇率数据(网络请求失败)"); toast.info("Using cached exchange rates (network request failed)");
} catch (e) { } catch (e) {
console.error("Failed to use cached rates:", e); console.error("Failed to use cached rates:", e);
if (error instanceof Error) { if (error instanceof Error) {
toast.error(`获取汇率失败: ${error.message}`); toast.error(`Failed to fetch rates: ${error.message}`);
} else { } else {
toast.error("获取汇率失败"); toast.error("Failed to fetch rates");
} }
} }
} else { } else {
if (error instanceof Error) { if (error instanceof Error) {
toast.error(`获取汇率失败: ${error.message}`); toast.error(`Failed to fetch rates: ${error.message}`);
} else { } else {
toast.error("获取汇率失败"); toast.error("Failed to fetch rates");
} }
} }
} finally { } finally {
@@ -171,13 +171,13 @@ const Tool: FC = () => {
fetchRates(); fetchRates();
}, []); }, []);
// 初始化金额并在汇率加载后计算其他货币 // Initialize amounts after rates are loaded
useEffect(() => { useEffect(() => {
if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) {
if (Object.keys(amounts).length === 0) { if (Object.keys(amounts).length === 0) {
// 首次初始化,以 1 USD 为基准 // Initial setup: 1 USD as base
const initialAmounts: Record<string, string> = {}; const initialAmounts: Record<string, string> = {};
const baseAmountInUSD = 1; // 1美元 const baseAmountInUSD = 1; // 1 USD
selectedCurrencies.forEach(code => { selectedCurrencies.forEach(code => {
if (rates[code]) { if (rates[code]) {
@@ -192,17 +192,17 @@ const Tool: FC = () => {
} }
}, [selectedCurrencies, rates, amounts]); }, [selectedCurrencies, rates, amounts]);
// 监听汇率更新,自动重新计算所有金额 // Auto-update amounts when rates change
useEffect(() => { useEffect(() => {
if (Object.keys(rates).length > 0 && Object.keys(amounts).length > 0) { if (Object.keys(rates).length > 0 && Object.keys(amounts).length > 0) {
// 找到第一个有值的货币作为基准 // Find first currency with value as base
const baseCurrency = selectedCurrencies.find(code => amounts[code] && parseFloat(amounts[code]) > 0); const baseCurrency = selectedCurrencies.find(code => amounts[code] && parseFloat(amounts[code]) > 0);
if (baseCurrency && rates[baseCurrency]) { if (baseCurrency && rates[baseCurrency]) {
const baseValue = parseFloat(amounts[baseCurrency]); const baseValue = parseFloat(amounts[baseCurrency]);
const amountInUSD = baseValue / rates[baseCurrency]; const amountInUSD = baseValue / rates[baseCurrency];
// 重新计算所有货币 // Recalculate all currencies
const updatedAmounts: Record<string, string> = {}; const updatedAmounts: Record<string, string> = {};
selectedCurrencies.forEach(code => { selectedCurrencies.forEach(code => {
if (rates[code]) { if (rates[code]) {
@@ -213,7 +213,7 @@ const Tool: FC = () => {
} }
}); });
// 只在值有变化时更新 // Only update if values changed
const hasChanged = selectedCurrencies.some( const hasChanged = selectedCurrencies.some(
code => updatedAmounts[code] !== amounts[code] code => updatedAmounts[code] !== amounts[code]
); );
@@ -225,9 +225,9 @@ const Tool: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rates]); }, [rates]);
// 处理货币金额变化 // Handle amount input change
const handleAmountChange = (currencyCode: string, value: string) => { const handleAmountChange = (currencyCode: string, value: string) => {
// 只允许数字和小数点 // Only allow numbers and decimal point
if (value !== "" && !/^\d*\.?\d*$/.test(value)) { if (value !== "" && !/^\d*\.?\d*$/.test(value)) {
return; return;
} }
@@ -237,7 +237,7 @@ const Tool: FC = () => {
return; return;
} }
// 如果输入为空,清空所有货币 // Clear all if empty
if (value === "" || value === "0" || parseFloat(value) === 0) { if (value === "" || value === "0" || parseFloat(value) === 0) {
const newAmounts: Record<string, string> = {}; const newAmounts: Record<string, string> = {};
selectedCurrencies.forEach(code => { selectedCurrencies.forEach(code => {
@@ -249,13 +249,13 @@ const Tool: FC = () => {
const inputValue = parseFloat(value); const inputValue = parseFloat(value);
// 计算其他货币的值 // Calculate other currencies
const newAmounts: Record<string, string> = { [currencyCode]: value }; const newAmounts: Record<string, string> = { [currencyCode]: value };
// 先转换为 USD // Convert to USD first
const amountInUSD = inputValue / rates[currencyCode]; const amountInUSD = inputValue / rates[currencyCode];
// 转换为其他货币 // Convert to other currencies
selectedCurrencies.forEach(code => { selectedCurrencies.forEach(code => {
if (code !== currencyCode && rates[code]) { if (code !== currencyCode && rates[code]) {
const convertedAmount = amountInUSD * rates[code]; const convertedAmount = amountInUSD * rates[code];
@@ -266,12 +266,12 @@ const Tool: FC = () => {
setAmounts(newAmounts); setAmounts(newAmounts);
}; };
// 添加货币 // Add currency
const addCurrency = (currencyCode: string) => { const addCurrency = (currencyCode: string) => {
if (!selectedCurrencies.includes(currencyCode)) { if (!selectedCurrencies.includes(currencyCode)) {
setSelectedCurrencies([...selectedCurrencies, currencyCode]); setSelectedCurrencies([...selectedCurrencies, currencyCode]);
// 计算新货币的初始金额 // Calculate initial amount for new currency
if (rates[currencyCode] && Object.keys(amounts).length > 0) { if (rates[currencyCode] && Object.keys(amounts).length > 0) {
const firstCurrency = selectedCurrencies[0]; const firstCurrency = selectedCurrencies[0];
if (firstCurrency && amounts[firstCurrency] && rates[firstCurrency]) { if (firstCurrency && amounts[firstCurrency] && rates[firstCurrency]) {
@@ -282,31 +282,31 @@ const Tool: FC = () => {
} }
} }
toast.success(`已添加 ${currencyCode}`); toast.success(`Added ${currencyCode}`);
} }
}; };
// 删除货币 // Remove currency
const removeCurrency = (currencyCode: string) => { const removeCurrency = (currencyCode: string) => {
if (REQUIRED_CURRENCIES.includes(currencyCode)) { if (REQUIRED_CURRENCIES.includes(currencyCode)) {
toast.error(`${currencyCode} 是必选货币,无法删除`); toast.error(`${currencyCode} is required and cannot be removed`);
return; return;
} }
if (selectedCurrencies.length <= 2) { if (selectedCurrencies.length <= 2) {
toast.error("至少需要保留两种货币"); toast.error("At least two currencies are required");
return; return;
} }
setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode)); setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode));
// 删除对应的金额 // Remove corresponding amount
const newAmounts = { ...amounts }; const newAmounts = { ...amounts };
delete newAmounts[currencyCode]; delete newAmounts[currencyCode];
setAmounts(newAmounts); setAmounts(newAmounts);
toast.success(`已删除 ${currencyCode}`); toast.success(`Removed ${currencyCode}`);
}; };
// 获取货币信息 // Get currency info
const getCurrency = (code: string): Currency | undefined => { const getCurrency = (code: string): Currency | undefined => {
return AVAILABLE_CURRENCIES.find((c) => c.code === code); return AVAILABLE_CURRENCIES.find((c) => c.code === code);
}; };
@@ -314,23 +314,23 @@ const Tool: FC = () => {
return ( return (
<div className="flex flex-col gap-4 h-full"> <div className="flex flex-col gap-4 h-full">
{/* 货币选择器 */} {/* Currency selector */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label></Label> <Label>Select Currencies</Label>
{/* 添加货币按钮 - 固定在右上角 */} {/* Add currency button - fixed at top right */}
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1"> <Button variant="outline" size="sm" className="gap-1">
<Plus className="size-4" /> <Plus className="size-4" />
Add Currency
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end" side="bottom" sideOffset={5}> <PopoverContent className="w-[300px] p-0" align="end" side="bottom" sideOffset={5}>
<Command> <Command>
<CommandInput placeholder="搜索货币..." /> <CommandInput placeholder="Search currencies..." />
<CommandList> <CommandList>
<CommandEmpty></CommandEmpty> <CommandEmpty>No currency found</CommandEmpty>
<CommandGroup> <CommandGroup>
{AVAILABLE_CURRENCIES.map((currency) => { {AVAILABLE_CURRENCIES.map((currency) => {
const isSelected = selectedCurrencies.includes(currency.code); const isSelected = selectedCurrencies.includes(currency.code);
@@ -341,7 +341,7 @@ const Tool: FC = () => {
value={`${currency.code} ${currency.name}`} value={`${currency.code} ${currency.name}`}
disabled={isRequired} disabled={isRequired}
onSelect={() => { onSelect={() => {
if (isRequired) return; // 必选货币不可操作 if (isRequired) return; // Required currencies cannot be toggled
if (isSelected && selectedCurrencies.length > 2) { if (isSelected && selectedCurrencies.length > 2) {
removeCurrency(currency.code); removeCurrency(currency.code);
} else if (!isSelected) { } else if (!isSelected) {
@@ -367,7 +367,7 @@ const Tool: FC = () => {
</Popover> </Popover>
</div> </div>
{/* 已选货币 Badges */} {/* Selected currency badges */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedCurrencies.map((code) => { {selectedCurrencies.map((code) => {
const currency = getCurrency(code); const currency = getCurrency(code);
@@ -398,18 +398,18 @@ const Tool: FC = () => {
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join("")} , {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join(", ")} are required. At least two currencies needed.
</span> </span>
</div> </div>
{/* 货币列表 */} {/* Currency list */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
... Loading exchange rates...
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 flex-1 overflow-auto"> <div className="flex flex-col gap-3 flex-1 overflow-auto">
<div className="text-sm font-medium">:</div> <div className="text-sm font-medium">Currency Amounts:</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 auto-rows-min"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 auto-rows-min">
{selectedCurrencies.map((code) => { {selectedCurrencies.map((code) => {
const currency = getCurrency(code); const currency = getCurrency(code);
@@ -425,7 +425,7 @@ const Tool: FC = () => {
<CardContent> <CardContent>
<Input <Input
type="text" type="text"
placeholder="请输入金额" placeholder="Enter amount"
value={amounts[code] || ""} value={amounts[code] || ""}
onChange={(e) => handleAmountChange(code, e.target.value)} onChange={(e) => handleAmountChange(code, e.target.value)}
className="text-xl font-semibold" className="text-xl font-semibold"
@@ -440,7 +440,16 @@ const Tool: FC = () => {
{!loading && Object.keys(rates).length > 0 && ( {!loading && Object.keys(rates).length > 0 && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
汇率数据来源: Frankfurter API () Exchange rate data from:{" "}
<a
href="https://www.frankfurter.app/"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Frankfurter API
</a>
{" "}(Based on European Central Bank data)
</div> </div>
)} )}
</div> </div>