From 4ceefdae4614c55bd2434b0e7cf0471c3f83d524 Mon Sep 17 00:00:00 2001 From: typist Date: Thu, 30 Oct 2025 08:59:04 +0800 Subject: [PATCH 1/5] feat: add new UI components and update dependencies - Introduced new components: Badge, Card, Command, Dialog, Label, and Popover for enhanced UI functionality. - Updated package.json and pnpm-lock.yaml to include new dependencies: @radix-ui/react-label, @radix-ui/react-popover, and cmdk. - Improved overall UI consistency and usability with the addition of these components. --- package.json | 3 + pnpm-lock.yaml | 85 ++++++++++++++++ src/components/ui/badge.tsx | 46 +++++++++ src/components/ui/card.tsx | 92 +++++++++++++++++ src/components/ui/command.tsx | 184 ++++++++++++++++++++++++++++++++++ src/components/ui/dialog.tsx | 141 ++++++++++++++++++++++++++ src/components/ui/label.tsx | 24 +++++ src/components/ui/popover.tsx | 46 +++++++++ 8 files changed, 621 insertions(+) create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx diff --git a/package.json b/package.json index 0b4d209..2efdb0e 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.548.0", "nanoid": "^5.1.6", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02395f..e83f41b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -38,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) lucide-react: specifier: ^0.548.0 version: 0.548.0(react@19.2.0) @@ -1050,6 +1059,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -1063,6 +1085,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1703,6 +1738,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4085,6 +4126,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4111,6 +4161,29 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -4736,6 +4809,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..6d51b6c --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } -- 2.49.1 From 8a565cf05f137d6168b2100dfa5a2caa3893268b Mon Sep 17 00:00:00 2001 From: typist Date: Thu, 30 Oct 2025 08:59:34 +0800 Subject: [PATCH 2/5] feat: add currency converter tool component - Introduced a new Currency Converter component that allows users to select and convert between various currencies in real-time. - Implemented local storage for saving selected currencies and caching for exchange rates to enhance user experience. - Updated the tool index to include the new Currency Converter with an appropriate icon and description. --- src/components/tool/currency.tsx | 451 +++++++++++++++++++++++++++++++ src/components/tool/index.tsx | 10 +- 2 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/components/tool/currency.tsx 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", -- 2.49.1 From 35287da53a5b2a1d19500b9d9b6131f810f95f2d Mon Sep 17 00:00:00 2001 From: typist Date: Thu, 30 Oct 2025 09:07:37 +0800 Subject: [PATCH 3/5] 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. --- src/components/tool/currency.tsx | 219 ++++++++++++++++--------------- 1 file changed, 114 insertions(+), 105 deletions(-) diff --git a/src/components/tool/currency.tsx b/src/components/tool/currency.tsx index 5e60566..1376f6f 100644 --- a/src/components/tool/currency.tsx +++ b/src/components/tool/currency.tsx @@ -15,46 +15,45 @@ interface Currency { symbol: string; } -// 常用货币列表 (仅包含 Frankfurter API 支持的货币) +// Available currencies (supported by 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" }, + { code: "USD", name: "US Dollar", symbol: "$" }, + { code: "CNY", name: "Chinese Yuan", symbol: "¥" }, + { code: "EUR", name: "Euro", symbol: "€" }, + { code: "GBP", name: "British Pound", symbol: "£" }, + { code: "JPY", name: "Japanese Yen", symbol: "¥" }, + { code: "HKD", name: "Hong Kong Dollar", symbol: "HK$" }, + { code: "AUD", name: "Australian Dollar", symbol: "A$" }, + { code: "CAD", name: "Canadian Dollar", symbol: "C$" }, + { code: "SGD", name: "Singapore Dollar", symbol: "S$" }, + { code: "CHF", name: "Swiss Franc", symbol: "CHF" }, + { code: "NZD", name: "New Zealand Dollar", symbol: "NZ$" }, + { code: "KRW", name: "South Korean Won", symbol: "₩" }, + { code: "THB", name: "Thai Baht", symbol: "฿" }, + { code: "MYR", name: "Malaysian Ringgit", symbol: "RM" }, + { code: "INR", name: "Indian Rupee", symbol: "₹" }, + { code: "BRL", name: "Brazilian Real", symbol: "R$" }, + { code: "MXN", name: "Mexican Peso", symbol: "MX$" }, + { code: "SEK", name: "Swedish Krona", symbol: "kr" }, + { code: "NOK", name: "Norwegian Krone", symbol: "kr" }, + { code: "DKK", name: "Danish Krone", symbol: "kr" }, + { code: "PLN", name: "Polish Złoty", symbol: "zł" }, + { code: "TRY", name: "Turkish Lira", symbol: "₺" }, + { code: "PHP", name: "Philippine Peso", symbol: "₱" }, + { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp" }, + { code: "ILS", name: "Israeli New Shekel", symbol: "₪" }, + { code: "CZK", name: "Czech Koruna", symbol: "Kč" }, + { code: "RON", name: "Romanian Leu", symbol: "lei" }, + { code: "HUF", name: "Hungarian Forint", symbol: "Ft" }, + { code: "BGN", name: "Bulgarian Lev", symbol: "лв" }, + { code: "ISK", name: "Icelandic Króna", symbol: "kr" }, ]; -// 必选货币(不可删除) - 美元第一,人民币第二 +// Required currencies (cannot be removed) - USD first, CNY second 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>({}); @@ -63,7 +62,7 @@ const Tool: FC = () => { const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); - // 从 localStorage 加载已选货币 + // Load selected currencies from localStorage useEffect(() => { try { const saved = localStorage.getItem(STORAGE_KEY); @@ -77,11 +76,11 @@ const Tool: FC = () => { } catch (error) { console.error("Failed to load saved currencies:", error); } - // 默认选择 USD 和 CNY (美元第一) + // Default: USD and CNY (USD first) setSelectedCurrencies(["USD", "CNY"]); }, []); - // 保存已选货币到 localStorage + // Save selected currencies to localStorage useEffect(() => { if (selectedCurrencies.length >= 2) { try { @@ -92,39 +91,39 @@ const Tool: FC = () => { } }, [selectedCurrencies]); - // 获取汇率数据 - 使用 stale-while-revalidate 缓存策略 + // Fetch exchange rates with date-based caching useEffect(() => { - const fetchRates = async (useCache = true) => { + const fetchRates = async () => { 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); + // 1. Try to load from cache + const cached = localStorage.getItem(RATES_CACHE_KEY); + if (cached) { + try { + const { rates: cachedRates, date: cachedDate } = JSON.parse(cached); + + // Check if cached data is from today + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + + if (cachedDate === today) { + // Cache is valid (same date), use it directly + setRates(cachedRates); + setLoading(false); + return; + } else { + // Cache is outdated, show old data first then update in background + setRates(cachedRates); + setLoading(false); + // 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); const response = await fetch("https://api.frankfurter.app/latest?base=USD", { - cache: "no-cache", // 绕过浏览器缓存,确保获取最新数据 + cache: "no-cache", }); if (!response.ok) { @@ -133,34 +132,35 @@ const Tool: FC = () => { const data = await response.json(); const allRates: Record = { USD: 1, ...data.rates }; + const apiDate = data.date; // Date from API (YYYY-MM-DD format) - // 3. 更新状态和缓存 + // 3. Update state and cache setRates(allRates); localStorage.setItem( RATES_CACHE_KEY, - JSON.stringify({ rates: allRates, timestamp: Date.now() }) + JSON.stringify({ rates: allRates, date: apiDate }) ); } catch (error) { - // 如果有缓存,即使网络请求失败也继续使用缓存 + // If cache exists, continue using it even if network fails const cached = localStorage.getItem(RATES_CACHE_KEY); if (cached) { try { const { rates: cachedRates } = JSON.parse(cached); setRates(cachedRates); - toast.info("使用缓存的汇率数据(网络请求失败)"); + toast.info("Using cached exchange rates (network request failed)"); } catch (e) { console.error("Failed to use cached rates:", e); if (error instanceof Error) { - toast.error(`获取汇率失败: ${error.message}`); + toast.error(`Failed to fetch rates: ${error.message}`); } else { - toast.error("获取汇率失败"); + toast.error("Failed to fetch rates"); } } } else { if (error instanceof Error) { - toast.error(`获取汇率失败: ${error.message}`); + toast.error(`Failed to fetch rates: ${error.message}`); } else { - toast.error("获取汇率失败"); + toast.error("Failed to fetch rates"); } } } finally { @@ -171,13 +171,13 @@ const Tool: FC = () => { fetchRates(); }, []); - // 初始化金额并在汇率加载后计算其他货币 + // Initialize amounts after rates are loaded useEffect(() => { if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { if (Object.keys(amounts).length === 0) { - // 首次初始化,以 1 USD 为基准 + // Initial setup: 1 USD as base const initialAmounts: Record = {}; - const baseAmountInUSD = 1; // 1美元 + const baseAmountInUSD = 1; // 1 USD selectedCurrencies.forEach(code => { if (rates[code]) { @@ -192,17 +192,17 @@ const Tool: FC = () => { } }, [selectedCurrencies, rates, amounts]); - // 监听汇率更新,自动重新计算所有金额 + // Auto-update amounts when rates change useEffect(() => { 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); if (baseCurrency && rates[baseCurrency]) { const baseValue = parseFloat(amounts[baseCurrency]); const amountInUSD = baseValue / rates[baseCurrency]; - // 重新计算所有货币 + // Recalculate all currencies const updatedAmounts: Record = {}; selectedCurrencies.forEach(code => { if (rates[code]) { @@ -213,7 +213,7 @@ const Tool: FC = () => { } }); - // 只在值有变化时更新 + // Only update if values changed const hasChanged = selectedCurrencies.some( code => updatedAmounts[code] !== amounts[code] ); @@ -225,9 +225,9 @@ const Tool: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [rates]); - // 处理货币金额变化 + // Handle amount input change const handleAmountChange = (currencyCode: string, value: string) => { - // 只允许数字和小数点 + // Only allow numbers and decimal point if (value !== "" && !/^\d*\.?\d*$/.test(value)) { return; } @@ -237,7 +237,7 @@ const Tool: FC = () => { return; } - // 如果输入为空,清空所有货币 + // Clear all if empty if (value === "" || value === "0" || parseFloat(value) === 0) { const newAmounts: Record = {}; selectedCurrencies.forEach(code => { @@ -249,13 +249,13 @@ const Tool: FC = () => { const inputValue = parseFloat(value); - // 计算其他货币的值 + // Calculate other currencies const newAmounts: Record = { [currencyCode]: value }; - // 先转换为 USD + // Convert to USD first const amountInUSD = inputValue / rates[currencyCode]; - // 转换为其他货币 + // Convert to other currencies selectedCurrencies.forEach(code => { if (code !== currencyCode && rates[code]) { const convertedAmount = amountInUSD * rates[code]; @@ -266,12 +266,12 @@ const Tool: FC = () => { setAmounts(newAmounts); }; - // 添加货币 + // Add currency const addCurrency = (currencyCode: string) => { if (!selectedCurrencies.includes(currencyCode)) { setSelectedCurrencies([...selectedCurrencies, currencyCode]); - // 计算新货币的初始金额 + // Calculate initial amount for new currency if (rates[currencyCode] && Object.keys(amounts).length > 0) { const firstCurrency = selectedCurrencies[0]; 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) => { if (REQUIRED_CURRENCIES.includes(currencyCode)) { - toast.error(`${currencyCode} 是必选货币,无法删除`); + toast.error(`${currencyCode} is required and cannot be removed`); return; } if (selectedCurrencies.length <= 2) { - toast.error("至少需要保留两种货币"); + toast.error("At least two currencies are required"); return; } setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode)); - // 删除对应的金额 + // Remove corresponding amount const newAmounts = { ...amounts }; delete newAmounts[currencyCode]; setAmounts(newAmounts); - toast.success(`已删除 ${currencyCode}`); + toast.success(`Removed ${currencyCode}`); }; - // 获取货币信息 + // Get currency info const getCurrency = (code: string): Currency | undefined => { return AVAILABLE_CURRENCIES.find((c) => c.code === code); }; @@ -314,23 +314,23 @@ const Tool: FC = () => { return (
- {/* 货币选择器 */} + {/* Currency selector */}
- - {/* 添加货币按钮 - 固定在右上角 */} + + {/* Add currency button - fixed at top right */} - + - 未找到货币 + No currency found {AVAILABLE_CURRENCIES.map((currency) => { const isSelected = selectedCurrencies.includes(currency.code); @@ -341,7 +341,7 @@ const Tool: FC = () => { value={`${currency.code} ${currency.name}`} disabled={isRequired} onSelect={() => { - if (isRequired) return; // 必选货币不可操作 + if (isRequired) return; // Required currencies cannot be toggled if (isSelected && selectedCurrencies.length > 2) { removeCurrency(currency.code); } else if (!isSelected) { @@ -367,7 +367,7 @@ const Tool: FC = () => {
- {/* 已选货币 Badges */} + {/* Selected currency badges */}
{selectedCurrencies.map((code) => { const currency = getCurrency(code); @@ -398,18 +398,18 @@ const Tool: FC = () => {
- {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join("、")} 为必选货币,至少保留两种货币 + {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join(", ")} are required. At least two currencies needed.
- {/* 货币列表 */} + {/* Currency list */} {loading ? (
- 加载汇率数据中... + Loading exchange rates...
) : (
-
货币金额:
+
Currency Amounts:
{selectedCurrencies.map((code) => { const currency = getCurrency(code); @@ -425,7 +425,7 @@ const Tool: FC = () => { handleAmountChange(code, e.target.value)} className="text-xl font-semibold" @@ -440,7 +440,16 @@ const Tool: FC = () => { {!loading && Object.keys(rates).length > 0 && (
- 汇率数据来源: Frankfurter API (基于欧洲央行数据) + Exchange rate data from:{" "} + + Frankfurter API + + {" "}(Based on European Central Bank data)
)}
-- 2.49.1 From 125733468e1279884683036982dd04315b52adbb Mon Sep 17 00:00:00 2001 From: typist Date: Thu, 30 Oct 2025 09:11:15 +0800 Subject: [PATCH 4/5] refactor: enhance currency badge interaction and styling - Updated the Badge component to improve user interaction with currency removal. - Enhanced styling for better visual feedback on hover and click actions. - Simplified the removal button functionality by integrating it into the Badge component. --- src/components/tool/currency.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/components/tool/currency.tsx b/src/components/tool/currency.tsx index 1376f6f..b447d6f 100644 --- a/src/components/tool/currency.tsx +++ b/src/components/tool/currency.tsx @@ -376,21 +376,22 @@ const Tool: FC = () => { { + if (!isRequired) { + removeCurrency(code); + } + }} > - {currency?.symbol} {code} + + {currency?.symbol} {code} + {!isRequired && ( - + )} ); -- 2.49.1 From d0501d87dbc66e39537f2b91af08106381862501 Mon Sep 17 00:00:00 2001 From: typist Date: Thu, 30 Oct 2025 09:22:27 +0800 Subject: [PATCH 5/5] refactor: improve currency display and layout in tool component - Updated currency display to enhance clarity by separating symbol, code, and name into distinct elements. - Adjusted styling for better alignment and responsiveness in the currency selection interface. - Improved overall user experience by refining the layout of currency information in the Card component. --- src/components/tool/currency.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/tool/currency.tsx b/src/components/tool/currency.tsx index b447d6f..306d0e0 100644 --- a/src/components/tool/currency.tsx +++ b/src/components/tool/currency.tsx @@ -350,13 +350,12 @@ const Tool: FC = () => { }} > - - {currency.symbol} {currency.code} - {currency.name} - + {currency.symbol} + {currency.code} ); })} @@ -420,7 +419,10 @@ const Tool: FC = () => { - {currency.symbol} {currency.code} - {currency.name} +
+
{currency.symbol} {currency.code}
+
{currency.name}
+
-- 2.49.1