Compare commits
	
		
			40 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d24389af66 | ||
| 97f38b44f5 | |||
|   | 62350492e9 | ||
|   | 9540c2b550 | ||
|   | 006f3d4dbb | ||
|   | ff8d497f97 | ||
|   | 9c2799f7d5 | ||
|   | dd70f9d886 | ||
|   | 35cccf6a8f | ||
| c5616600fa | |||
|   | 986708fbb4 | ||
|   | 6a1b68ed2c | ||
|   | 32970acf32 | ||
|   | 812bb8c248 | ||
|   | 09f9e6588f | ||
|   | 10a167febd | ||
|   | 91c0686a46 | ||
|   | 40bfde8e57 | ||
|   | b4ba7a2219 | ||
|   | 25e42e3af5 | ||
|   | 4398b53ea7 | ||
|   | 3e14bc652f | ||
|   | aca7e11835 | ||
|   | 782de6e38a | ||
|   | 7646830194 | ||
|   | 59f998e8e3 | ||
|   | 55207beff5 | ||
|   | e98a344b95 | ||
|   | d553c3e04c | ||
|   | e2da2758cc | ||
|   | 3ab70498e6 | ||
|   | a5ef1a1e70 | ||
|   | 297000f208 | ||
|   | 04fbf12e07 | ||
| a9a6354b2d | |||
|   | 109139a42e | ||
|   | b5a811e5ee | ||
|   | b3adfe5c8f | ||
|   | 8eda2eae99 | ||
|   | 99673913a6 | 
| @@ -43,8 +43,8 @@ jobs: | ||||
|           push: true | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|           cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache | ||||
|           cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max | ||||
|           # cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache | ||||
|           # cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max | ||||
|           # platforms: linux/amd64,linux/arm64 | ||||
|           platforms: linux/amd64 | ||||
|        | ||||
|   | ||||
							
								
								
									
										63
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								index.html
									
									
									
									
									
								
							| @@ -2,12 +2,71 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/lite.svg" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Lite Kit</title> | ||||
|      | ||||
|     <!-- Primary Meta Tags --> | ||||
|     <title>Lite Kit - Lightweight Online Tools</title> | ||||
|     <meta name="description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more. Fast, secure, and easy to use." /> | ||||
|     <meta name="keywords" content="online tools,UUID generator,JSON formatter,Base64 encoder,network tools,DNS lookup,Ping test,TCPing,speed test,IP query" /> | ||||
|     <meta name="author" content="Lite Kit" /> | ||||
|     <meta name="theme-color" content="#000000" /> | ||||
|      | ||||
|     <!-- Favicon --> | ||||
|     <link rel="icon" type="image/svg+xml" href="/lite.svg" /> | ||||
|      | ||||
|     <!-- DNS Prefetch & Preconnect for external APIs --> | ||||
|     <link rel="dns-prefetch" href="https://ipinfo.io"> | ||||
|     <link rel="preconnect" href="https://ipinfo.io" crossorigin> | ||||
|      | ||||
|     <!-- Google Fonts --> | ||||
|     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||
|     <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500&display=swap" rel="stylesheet"> | ||||
|      | ||||
|     <!-- Open Graph / Facebook --> | ||||
|     <meta property="og:type" content="website" /> | ||||
|     <meta property="og:url" content="https://litek.typist.cc/" /> | ||||
|     <meta property="og:title" content="Lite Kit - Lightweight Online Tools" /> | ||||
|     <meta property="og:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." /> | ||||
|     <meta property="og:image" content="https://litek.typist.cc/lite.svg" /> | ||||
|     <meta property="og:site_name" content="Lite Kit" /> | ||||
|      | ||||
|     <!-- Twitter Card --> | ||||
|     <meta name="twitter:card" content="summary_large_image" /> | ||||
|     <meta name="twitter:title" content="Lite Kit - Lightweight Online Tools" /> | ||||
|     <meta name="twitter:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." /> | ||||
|     <meta name="twitter:image" content="https://litek.typist.cc/lite.svg" /> | ||||
|      | ||||
|     <!-- PWA Manifest --> | ||||
|     <link rel="manifest" href="/manifest.json" /> | ||||
|      | ||||
|     <!-- Structured Data (JSON-LD) --> | ||||
|     <script type="application/ld+json"> | ||||
|     { | ||||
|       "@context": "https://schema.org", | ||||
|       "@type": "WebSite", | ||||
|       "name": "Lite Kit", | ||||
|       "description": "Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more", | ||||
|       "url": "https://litek.typist.cc/", | ||||
|       "author": { | ||||
|         "@type": "Organization", | ||||
|         "name": "Lite Kit" | ||||
|       }, | ||||
|       "applicationCategory": "UtilitiesApplication", | ||||
|       "offers": { | ||||
|         "@type": "Offer", | ||||
|         "price": "0", | ||||
|         "priceCurrency": "USD" | ||||
|       } | ||||
|     } | ||||
|     </script> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|  | ||||
|     <!-- Cloudflare Web Analytics --> | ||||
|     <script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "2aecdc025eb043bc89ce931b54a80054"}'></script> | ||||
|     <!-- End Cloudflare Web Analytics --> | ||||
|   </body> | ||||
| </html> | ||||
|   | ||||
							
								
								
									
										15
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,11 +1,12 @@ | ||||
| { | ||||
|   "name": "litek", | ||||
|   "private": true, | ||||
|   "version": "0.0.12", | ||||
|   "version": "0.0.30", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "tsc -b && vite build", | ||||
|     "generate:sitemap": "tsx scripts/generate-sitemap.ts", | ||||
|     "lint": "eslint .", | ||||
|     "preview": "vite preview", | ||||
|     "release:patch": "npm version patch && git push --follow-tags", | ||||
| @@ -16,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", | ||||
| @@ -43,17 +47,22 @@ | ||||
|     "eslint-plugin-react-hooks": "^5.2.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.22", | ||||
|     "globals": "^16.4.0", | ||||
|     "terser": "^5.44.0", | ||||
|     "tsx": "^4.19.2", | ||||
|     "tw-animate-css": "^1.4.0", | ||||
|     "typescript": "~5.9.3", | ||||
|     "typescript-eslint": "^8.45.0", | ||||
|     "vite": "npm:rolldown-vite@7.1.14" | ||||
|     "vite": "npm:rolldown-vite@7.1.14", | ||||
|     "vite-plugin-pwa": "^1.1.0", | ||||
|     "workbox-window": "^7.3.0" | ||||
|   }, | ||||
|   "pnpm": { | ||||
|     "overrides": { | ||||
|       "vite": "npm:rolldown-vite@7.1.14" | ||||
|     }, | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@swc/core" | ||||
|       "@swc/core", | ||||
|       "esbuild" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										3193
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3193
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										21
									
								
								public/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								public/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| { | ||||
|   "name": "Lite Kit - Lightweight Online Tools", | ||||
|   "short_name": "Lite Kit", | ||||
|   "description": "Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more", | ||||
|   "start_url": "/", | ||||
|   "display": "standalone", | ||||
|   "background_color": "#000000", | ||||
|   "theme_color": "#000000", | ||||
|   "orientation": "portrait-primary", | ||||
|   "icons": [ | ||||
|     { | ||||
|       "src": "/lite.svg", | ||||
|       "type": "image/svg+xml", | ||||
|       "sizes": "any" | ||||
|     } | ||||
|   ], | ||||
|   "categories": ["utilities", "productivity"], | ||||
|   "lang": "en", | ||||
|   "dir": "ltr" | ||||
| } | ||||
|  | ||||
							
								
								
									
										21
									
								
								public/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								public/robots.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| # Allow all crawlers | ||||
| User-agent: * | ||||
| Allow: / | ||||
|  | ||||
| # Sitemaps | ||||
| Sitemap: https://litek.typist.cc/sitemap.xml | ||||
|  | ||||
| # Common bots | ||||
| User-agent: Googlebot | ||||
| Allow: / | ||||
|  | ||||
| User-agent: Bingbot | ||||
| Allow: / | ||||
|  | ||||
| User-agent: Baiduspider | ||||
| Allow: / | ||||
|  | ||||
| # Crawl-delay for less aggressive bots | ||||
| User-agent: * | ||||
| Crawl-delay: 1 | ||||
|  | ||||
							
								
								
									
										63
									
								
								public/sitemap.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								public/sitemap.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>weekly</changefreq> | ||||
|     <priority>1.0</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>weekly</changefreq> | ||||
|     <priority>0.9</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/uuid</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.9</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/json</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.9</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/base64</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.9</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/network/dns</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.8</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/network/ping</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.8</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/network/tcping</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.8</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/network/speedtest</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.8</priority> | ||||
|   </url> | ||||
|   <url> | ||||
|     <loc>https://litek.typist.cc/tool/network/ipquery</loc> | ||||
|     <lastmod>2025-10-29</lastmod> | ||||
|     <changefreq>monthly</changefreq> | ||||
|     <priority>0.8</priority> | ||||
|   </url> | ||||
| </urlset> | ||||
							
								
								
									
										83
									
								
								scripts/generate-sitemap.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								scripts/generate-sitemap.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import { fileURLToPath } from 'url'; | ||||
|  | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = path.dirname(__filename); | ||||
|  | ||||
| interface SitemapUrl { | ||||
|   loc: string; | ||||
|   lastmod: string; | ||||
|   changefreq: string; | ||||
|   priority: string; | ||||
| } | ||||
|  | ||||
| const BASE_URL = 'https://litek.typist.cc'; | ||||
| const currentDate = new Date().toISOString().split('T')[0]; | ||||
|  | ||||
| const tools = [ | ||||
|   { path: '/tool/uuid', priority: '0.9' }, | ||||
|   { path: '/tool/json', priority: '0.9' }, | ||||
|   { path: '/tool/base64', priority: '0.9' }, | ||||
|   { path: '/tool/network/dns', priority: '0.8' }, | ||||
|   { path: '/tool/network/ping', priority: '0.8' }, | ||||
|   { path: '/tool/network/tcping', priority: '0.8' }, | ||||
|   { path: '/tool/network/speedtest', priority: '0.8' }, | ||||
|   { path: '/tool/network/ipquery', priority: '0.8' }, | ||||
| ]; | ||||
|  | ||||
| const urls: SitemapUrl[] = [ | ||||
|   { | ||||
|     loc: BASE_URL, | ||||
|     lastmod: currentDate, | ||||
|     changefreq: 'weekly', | ||||
|     priority: '1.0', | ||||
|   }, | ||||
|   { | ||||
|     loc: `${BASE_URL}/tool`, | ||||
|     lastmod: currentDate, | ||||
|     changefreq: 'weekly', | ||||
|     priority: '0.9', | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| // Add all tool pages | ||||
| tools.forEach((tool) => { | ||||
|   urls.push({ | ||||
|     loc: `${BASE_URL}${tool.path}`, | ||||
|     lastmod: currentDate, | ||||
|     changefreq: 'monthly', | ||||
|     priority: tool.priority, | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| function generateSitemap(): string { | ||||
|   let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; | ||||
|   xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'; | ||||
|  | ||||
|   urls.forEach((url) => { | ||||
|     xml += '  <url>\n'; | ||||
|     xml += `    <loc>${url.loc}</loc>\n`; | ||||
|     xml += `    <lastmod>${url.lastmod}</lastmod>\n`; | ||||
|     xml += `    <changefreq>${url.changefreq}</changefreq>\n`; | ||||
|     xml += `    <priority>${url.priority}</priority>\n`; | ||||
|     xml += '  </url>\n'; | ||||
|   }); | ||||
|  | ||||
|   xml += '</urlset>\n'; | ||||
|   return xml; | ||||
| } | ||||
|  | ||||
| // Generate and write sitemap | ||||
| const sitemap = generateSitemap(); | ||||
| const publicDir = path.resolve(__dirname, '../public'); | ||||
| const sitemapPath = path.join(publicDir, 'sitemap.xml'); | ||||
|  | ||||
| // Ensure public directory exists | ||||
| if (!fs.existsSync(publicDir)) { | ||||
|   fs.mkdirSync(publicDir, { recursive: true }); | ||||
| } | ||||
|  | ||||
| fs.writeFileSync(sitemapPath, sitemap, 'utf-8'); | ||||
| console.log(`✅ Sitemap generated successfully at ${sitemapPath}`); | ||||
|  | ||||
| @@ -1,11 +1,11 @@ | ||||
| import type { ReactNode } from "react"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import { ChevronRight } from "lucide-react"; | ||||
|  | ||||
| import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar"; | ||||
| import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; | ||||
| import { tools, type Tool } from "@/components/tool"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import { ModeToggle } from "@/components/theme/toggle"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { ChevronRight } from "lucide-react"; | ||||
|  | ||||
|  | ||||
| export const AppSidebar = () => { | ||||
|   // 递归构建完整路径 | ||||
| @@ -13,6 +13,46 @@ export const AppSidebar = () => { | ||||
|     return `/tool/${pathSegments.join("/")}`; | ||||
|   }; | ||||
|  | ||||
|   // 递归渲染子菜单内容 | ||||
|   const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => { | ||||
|     if (child.children) { | ||||
|       // 子菜单内的可折叠项 | ||||
|       return ( | ||||
|         <Collapsible defaultOpen={false} className="group/collapsible"> | ||||
|           <CollapsibleTrigger asChild> | ||||
|             <SidebarMenuSubButton> | ||||
|               {child.icon} | ||||
|               <span>{child.name}</span> | ||||
|               <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> | ||||
|             </SidebarMenuSubButton> | ||||
|           </CollapsibleTrigger> | ||||
|           <CollapsibleContent> | ||||
|             <SidebarMenuSub> | ||||
|               {child.children.map((subChild) => ( | ||||
|                 <SidebarMenuSubItem key={subChild.name}> | ||||
|                   {renderSubMenuContent(subChild, [...currentPaths, child.path])} | ||||
|                 </SidebarMenuSubItem> | ||||
|               ))} | ||||
|             </SidebarMenuSub> | ||||
|           </CollapsibleContent> | ||||
|         </Collapsible> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // 叶子节点 | ||||
|     return ( | ||||
|       <SidebarMenuSubButton asChild> | ||||
|         <Link | ||||
|           to={buildFullPath([...currentPaths, child.path])} | ||||
|           title={child.description} | ||||
|         > | ||||
|           {child.icon} | ||||
|           <span>{child.name}</span> | ||||
|         </Link> | ||||
|       </SidebarMenuSubButton> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   // 递归渲染菜单项 | ||||
|   const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => { | ||||
|     const currentPaths = [...parentPaths, tool.path]; | ||||
| @@ -20,12 +60,11 @@ export const AppSidebar = () => { | ||||
|     if (tool.children) { | ||||
|       // 有子菜单的项目 | ||||
|       return ( | ||||
|         <Collapsible | ||||
|           key={tool.name} | ||||
|           defaultOpen={false} | ||||
|           className="group/collapsible" | ||||
|         > | ||||
|           <SidebarMenuItem> | ||||
|         <SidebarMenuItem key={tool.name}> | ||||
|           <Collapsible | ||||
|             defaultOpen={false} | ||||
|             className="group/collapsible" | ||||
|           > | ||||
|             <CollapsibleTrigger asChild> | ||||
|               <SidebarMenuButton tooltip={tool.description}> | ||||
|                 {tool.icon} | ||||
| @@ -37,25 +76,13 @@ export const AppSidebar = () => { | ||||
|               <SidebarMenuSub> | ||||
|                 {tool.children.map((child) => ( | ||||
|                   <SidebarMenuSubItem key={child.name}> | ||||
|                     {child.children ? ( | ||||
|                       renderMenuItem(child, currentPaths) | ||||
|                     ) : ( | ||||
|                       <SidebarMenuSubButton asChild> | ||||
|                         <Link | ||||
|                           to={buildFullPath([...currentPaths, child.path])} | ||||
|                           title={child.description} | ||||
|                         > | ||||
|                           {child.icon} | ||||
|                           <span>{child.name}</span> | ||||
|                         </Link> | ||||
|                       </SidebarMenuSubButton> | ||||
|                     )} | ||||
|                     {renderSubMenuContent(child, currentPaths)} | ||||
|                   </SidebarMenuSubItem> | ||||
|                 ))} | ||||
|               </SidebarMenuSub> | ||||
|             </CollapsibleContent> | ||||
|           </SidebarMenuItem> | ||||
|         </Collapsible> | ||||
|           </Collapsible> | ||||
|         </SidebarMenuItem> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -87,12 +114,7 @@ export const AppSidebar = () => { | ||||
|           </SidebarGroupContent> | ||||
|         </SidebarGroup> | ||||
|       </SidebarContent> | ||||
|       <SidebarFooter className="flex flex-row justify-between items-center gap-2"> | ||||
|         <Button variant="link"> | ||||
|           <a href="mailto:litek@mail.typist.cc">need more tools?</a> | ||||
|         </Button> | ||||
|         <ModeToggle /> | ||||
|       </SidebarFooter> | ||||
|       <SidebarFooter className="flex flex-col gap-2" /> | ||||
|     </Sidebar> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										463
									
								
								src/components/tool/currency.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										463
									
								
								src/components/tool/currency.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,463 @@ | ||||
| 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; | ||||
| } | ||||
|  | ||||
| // Available currencies (supported by Frankfurter API) | ||||
| const AVAILABLE_CURRENCIES: Currency[] = [ | ||||
|   { 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 Tool: FC = () => { | ||||
|   const [amounts, setAmounts] = useState<Record<string, string>>({}); | ||||
|   const [selectedCurrencies, setSelectedCurrencies] = useState<string[]>([]); | ||||
|   const [rates, setRates] = useState<Record<string, number>>({}); | ||||
|   const [loading, setLoading] = useState<boolean>(false); | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   // Load selected currencies from 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); | ||||
|     } | ||||
|     // Default: USD and CNY (USD first) | ||||
|     setSelectedCurrencies(["USD", "CNY"]); | ||||
|   }, []); | ||||
|  | ||||
|   // Save selected currencies to localStorage | ||||
|   useEffect(() => { | ||||
|     if (selectedCurrencies.length >= 2) { | ||||
|       try { | ||||
|         localStorage.setItem(STORAGE_KEY, JSON.stringify(selectedCurrencies)); | ||||
|       } catch (error) { | ||||
|         console.error("Failed to save currencies:", error); | ||||
|       } | ||||
|     } | ||||
|   }, [selectedCurrencies]); | ||||
|  | ||||
|   // Fetch exchange rates with date-based caching | ||||
|   useEffect(() => { | ||||
|     const fetchRates = async () => { | ||||
|       try { | ||||
|         // 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. Fetch latest data from network | ||||
|         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<string, number> = { USD: 1, ...data.rates }; | ||||
|         const apiDate = data.date; // Date from API (YYYY-MM-DD format) | ||||
|          | ||||
|         // 3. Update state and cache | ||||
|         setRates(allRates); | ||||
|         localStorage.setItem( | ||||
|           RATES_CACHE_KEY, | ||||
|           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("Using cached exchange rates (network request failed)"); | ||||
|           } catch (e) { | ||||
|             console.error("Failed to use cached rates:", e); | ||||
|             if (error instanceof Error) { | ||||
|               toast.error(`Failed to fetch rates: ${error.message}`); | ||||
|             } else { | ||||
|               toast.error("Failed to fetch rates"); | ||||
|             } | ||||
|           } | ||||
|         } else { | ||||
|           if (error instanceof Error) { | ||||
|             toast.error(`Failed to fetch rates: ${error.message}`); | ||||
|           } else { | ||||
|             toast.error("Failed to fetch rates"); | ||||
|           } | ||||
|         } | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     fetchRates(); | ||||
|   }, []); | ||||
|  | ||||
|   // Initialize amounts after rates are loaded | ||||
|   useEffect(() => { | ||||
|     if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { | ||||
|       if (Object.keys(amounts).length === 0) { | ||||
|         // Initial setup: 1 USD as base | ||||
|         const initialAmounts: Record<string, string> = {}; | ||||
|         const baseAmountInUSD = 1; // 1 USD | ||||
|          | ||||
|         selectedCurrencies.forEach(code => { | ||||
|           if (rates[code]) { | ||||
|             const convertedAmount = baseAmountInUSD * rates[code]; | ||||
|             initialAmounts[code] = convertedAmount.toFixed(2); | ||||
|           } else { | ||||
|             initialAmounts[code] = ""; | ||||
|           } | ||||
|         }); | ||||
|         setAmounts(initialAmounts); | ||||
|       } | ||||
|     } | ||||
|   }, [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<string, string> = {}; | ||||
|         selectedCurrencies.forEach(code => { | ||||
|           if (rates[code]) { | ||||
|             const convertedAmount = amountInUSD * rates[code]; | ||||
|             updatedAmounts[code] = convertedAmount.toFixed(2); | ||||
|           } else { | ||||
|             updatedAmounts[code] = amounts[code] || ""; | ||||
|           } | ||||
|         }); | ||||
|          | ||||
|         // Only update if values changed | ||||
|         const hasChanged = selectedCurrencies.some( | ||||
|           code => updatedAmounts[code] !== amounts[code] | ||||
|         ); | ||||
|         if (hasChanged) { | ||||
|           setAmounts(updatedAmounts); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     // 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; | ||||
|     } | ||||
|  | ||||
|     if (!rates[currencyCode]) { | ||||
|       setAmounts({ ...amounts, [currencyCode]: value }); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Clear all if empty | ||||
|     if (value === "" || value === "0" || parseFloat(value) === 0) { | ||||
|       const newAmounts: Record<string, string> = {}; | ||||
|       selectedCurrencies.forEach(code => { | ||||
|         newAmounts[code] = ""; | ||||
|       }); | ||||
|       setAmounts(newAmounts); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const inputValue = parseFloat(value); | ||||
|      | ||||
|     // Calculate other currencies | ||||
|     const newAmounts: Record<string, string> = { [currencyCode]: value }; | ||||
|      | ||||
|     // 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]; | ||||
|         newAmounts[code] = convertedAmount.toFixed(2); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     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]) { | ||||
|           const firstAmount = parseFloat(amounts[firstCurrency]) || 0; | ||||
|           const amountInUSD = firstAmount / rates[firstCurrency]; | ||||
|           const newAmount = amountInUSD * rates[currencyCode]; | ||||
|           setAmounts({ ...amounts, [currencyCode]: newAmount.toFixed(2) }); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       toast.success(`Added ${currencyCode}`); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // Remove currency | ||||
|   const removeCurrency = (currencyCode: string) => { | ||||
|     if (REQUIRED_CURRENCIES.includes(currencyCode)) { | ||||
|       toast.error(`${currencyCode} is required and cannot be removed`); | ||||
|       return; | ||||
|     } | ||||
|     if (selectedCurrencies.length <= 2) { | ||||
|       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(`Removed ${currencyCode}`); | ||||
|   }; | ||||
|  | ||||
|   // Get currency info | ||||
|   const getCurrency = (code: string): Currency | undefined => { | ||||
|     return AVAILABLE_CURRENCIES.find((c) => c.code === code); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4 h-full"> | ||||
|  | ||||
|       {/* Currency selector */} | ||||
|       <div className="flex flex-col gap-3"> | ||||
|         <div className="flex items-center justify-between"> | ||||
|           <Label>Select Currencies</Label> | ||||
|           {/* Add currency button - fixed at top right */} | ||||
|           <Popover open={open} onOpenChange={setOpen}> | ||||
|             <PopoverTrigger asChild> | ||||
|               <Button variant="outline" size="sm" className="gap-1"> | ||||
|                 <Plus className="size-4" /> | ||||
|                 Add Currency | ||||
|               </Button> | ||||
|             </PopoverTrigger> | ||||
|             <PopoverContent className="w-[300px] p-0" align="end" side="bottom" sideOffset={5}> | ||||
|               <Command> | ||||
|                 <CommandInput placeholder="Search currencies..." /> | ||||
|                 <CommandList> | ||||
|                   <CommandEmpty>No currency found</CommandEmpty> | ||||
|                   <CommandGroup> | ||||
|                     {AVAILABLE_CURRENCIES.map((currency) => { | ||||
|                       const isSelected = selectedCurrencies.includes(currency.code); | ||||
|                       const isRequired = REQUIRED_CURRENCIES.includes(currency.code); | ||||
|                       return ( | ||||
|                         <CommandItem | ||||
|                           key={currency.code} | ||||
|                           value={`${currency.code} ${currency.name}`} | ||||
|                           disabled={isRequired} | ||||
|                           onSelect={() => { | ||||
|                             if (isRequired) return; // Required currencies cannot be toggled | ||||
|                             if (isSelected && selectedCurrencies.length > 2) { | ||||
|                               removeCurrency(currency.code); | ||||
|                             } else if (!isSelected) { | ||||
|                               addCurrency(currency.code); | ||||
|                             } | ||||
|                           }} | ||||
|                         > | ||||
|                           <Check | ||||
|                             className={`mr-2 size-4 flex-shrink-0 ${ | ||||
|                               isSelected ? "opacity-100" : "opacity-0" | ||||
|                             }`} | ||||
|                           /> | ||||
|                           <span className="inline-block w-8 text-right flex-shrink-0">{currency.symbol}</span> | ||||
|                           <span className="ml-2">{currency.code}</span> | ||||
|                         </CommandItem> | ||||
|                       ); | ||||
|                     })} | ||||
|                   </CommandGroup> | ||||
|                 </CommandList> | ||||
|               </Command> | ||||
|             </PopoverContent> | ||||
|           </Popover> | ||||
|         </div> | ||||
|          | ||||
|         {/* Selected currency badges */} | ||||
|         <div className="flex flex-wrap gap-2"> | ||||
|           {selectedCurrencies.map((code) => { | ||||
|             const currency = getCurrency(code); | ||||
|             const isRequired = REQUIRED_CURRENCIES.includes(code); | ||||
|             return ( | ||||
|               <Badge | ||||
|                 key={code} | ||||
|                 variant={isRequired ? "default" : "secondary"} | ||||
|                 className={`relative gap-1 px-2.5 py-1 transition-all duration-200 ${ | ||||
|                   !isRequired  | ||||
|                     ? "cursor-pointer hover:bg-destructive hover:text-destructive-foreground hover:border-destructive group"  | ||||
|                     : "" | ||||
|                 }`} | ||||
|                 onClick={() => { | ||||
|                   if (!isRequired) { | ||||
|                     removeCurrency(code); | ||||
|                   } | ||||
|                 }} | ||||
|               > | ||||
|                 <span className={!isRequired ? "group-hover:opacity-0 transition-opacity duration-200" : ""}> | ||||
|                   {currency?.symbol} {code} | ||||
|                 </span> | ||||
|                 {!isRequired && ( | ||||
|                   <X className="absolute inset-0 m-auto size-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" /> | ||||
|                 )} | ||||
|               </Badge> | ||||
|             ); | ||||
|           })} | ||||
|         </div> | ||||
|          | ||||
|         <span className="text-xs text-muted-foreground"> | ||||
|           {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join(", ")} are required. At least two currencies needed. | ||||
|         </span> | ||||
|       </div> | ||||
|  | ||||
|       {/* Currency list */} | ||||
|       {loading ? ( | ||||
|         <div className="flex items-center justify-center py-8 text-muted-foreground"> | ||||
|           Loading exchange rates... | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <div className="flex flex-col gap-3 flex-1 overflow-auto"> | ||||
|           <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"> | ||||
|             {selectedCurrencies.map((code) => { | ||||
|               const currency = getCurrency(code); | ||||
|               if (!currency) return null; | ||||
|                | ||||
|               return ( | ||||
|                 <Card key={code} className="flex flex-col"> | ||||
|                   <CardHeader className="pb-3"> | ||||
|                     <CardTitle className="text-sm font-medium text-muted-foreground"> | ||||
|                       <div className="leading-tight"> | ||||
|                         <div>{currency.symbol} {currency.code}</div> | ||||
|                         <div className="text-xs font-normal mt-1">{currency.name}</div> | ||||
|                       </div> | ||||
|                     </CardTitle> | ||||
|                   </CardHeader> | ||||
|                   <CardContent> | ||||
|                     <Input | ||||
|                       type="text" | ||||
|                       placeholder="Enter amount" | ||||
|                       value={amounts[code] || ""} | ||||
|                       onChange={(e) => handleAmountChange(code, e.target.value)} | ||||
|                       className="text-xl font-semibold" | ||||
|                     /> | ||||
|                   </CardContent> | ||||
|                 </Card> | ||||
|               ); | ||||
|             })} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {!loading && Object.keys(rates).length > 0 && ( | ||||
|         <div className="text-xs text-muted-foreground"> | ||||
|           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> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
| @@ -1,17 +1,23 @@ | ||||
| import type { ReactNode } from 'react'; | ||||
| import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react' | ||||
| import { lazy, type ReactNode, type ComponentType } from 'react'; | ||||
| import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin, Coins } from 'lucide-react' | ||||
|  | ||||
| import UUID from './uuid' | ||||
| import JSON from './json' | ||||
| import Base64 from './base64' | ||||
| import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network' | ||||
| // 懒加载工具组件 | ||||
| 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')) | ||||
| const SpeedTest = lazy(() => import('./network/speedtest')) | ||||
| const IPQuery = lazy(() => import('./network/ipquery')) | ||||
|  | ||||
| export interface Tool { | ||||
|   path: string; | ||||
|   name: string; | ||||
|   icon: ReactNode; | ||||
|   description: string; | ||||
|   component?: ReactNode; | ||||
|   component?: ComponentType; | ||||
|   children?: Tool[]; | ||||
| } | ||||
|  | ||||
| @@ -21,21 +27,28 @@ export const tools: Tool[] = [ | ||||
|     name: "UUID Generator", | ||||
|     description: "Generate a UUID", | ||||
|     icon: <Hash />, | ||||
|     component: <UUID />, | ||||
|     component: UUID, | ||||
|   }, | ||||
|   { | ||||
|     path: "json", | ||||
|     name: "JSON Formatter", | ||||
|     description: "Format and validate JSON", | ||||
|     icon: <FileJson />, | ||||
|     component: <JSON />, | ||||
|     component: JSON, | ||||
|   }, | ||||
|   { | ||||
|     path: "base64", | ||||
|     name: "Base64 Encoder/Decoder", | ||||
|     description: "Encode and decode Base64", | ||||
|     icon: <Binary />, | ||||
|     component: <Base64 />, | ||||
|     component: Base64, | ||||
|   }, | ||||
|   { | ||||
|     path: "currency", | ||||
|     name: "Currency Converter", | ||||
|     description: "Real-time currency exchange rates", | ||||
|     icon: <Coins />, | ||||
|     component: Currency, | ||||
|   }, | ||||
|   { | ||||
|     path: "network", | ||||
| @@ -48,35 +61,35 @@ export const tools: Tool[] = [ | ||||
|         name: "DNS Lookup", | ||||
|         description: "DNS query tool", | ||||
|         icon: <Globe />, | ||||
|         component: <DNS />, | ||||
|         component: DNS, | ||||
|       }, | ||||
|       { | ||||
|         path: "ping", | ||||
|         name: "Ping", | ||||
|         description: "Ping test tool", | ||||
|         icon: <Activity />, | ||||
|         component: <Ping />, | ||||
|         component: Ping, | ||||
|       }, | ||||
|       { | ||||
|         path: "tcping", | ||||
|         name: "TCPing", | ||||
|         description: "TCP port connectivity test", | ||||
|         icon: <Wifi />, | ||||
|         component: <TCPing />, | ||||
|         component: TCPing, | ||||
|       }, | ||||
|       { | ||||
|         path: "speedtest", | ||||
|         name: "Speed Test", | ||||
|         description: "Website speed test", | ||||
|         icon: <Gauge />, | ||||
|         component: <SpeedTest />, | ||||
|         component: SpeedTest, | ||||
|       }, | ||||
|       { | ||||
|         path: "ipquery", | ||||
|         name: "IP Query", | ||||
|         description: "Query IP location, quality and risk info", | ||||
|         icon: <MapPin />, | ||||
|         component: <IPQuery />, | ||||
|         component: IPQuery, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   | ||||
| @@ -1,6 +0,0 @@ | ||||
| export { default as DNS } from './dns'; | ||||
| export { default as Ping } from './ping'; | ||||
| export { default as TCPing } from './tcping'; | ||||
| export { default as SpeedTest } from './speedtest'; | ||||
| export { default as IPQuery } from './ipquery'; | ||||
|  | ||||
| @@ -91,8 +91,8 @@ const Tool: FC = () => { | ||||
|     const startTime = performance.now(); | ||||
|  | ||||
|     try { | ||||
|       // 使用 ip-api.com (免费,功能较全) | ||||
|       const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip.trim())}?fields=status,message,country,countryCode,region,city,lat,lon,timezone,isp,org,as,proxy,hosting,query`); | ||||
|       // 使用 ipinfo.io (免费,稳定可靠) | ||||
|       const response = await fetch(`https://ipinfo.io/${encodeURIComponent(ip.trim())}/json`); | ||||
|        | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP error! status: ${response.status}`); | ||||
| @@ -103,28 +103,8 @@ const Tool: FC = () => { | ||||
|        | ||||
|       setQueryTime(endTime - startTime); | ||||
|  | ||||
|       if (data.status === "fail") { | ||||
|         toast.error(data.message || "Query failed"); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // 转换为统一格式 | ||||
|       const ipData: IPInfo = { | ||||
|         ip: data.query, | ||||
|         city: data.city, | ||||
|         region: data.region, | ||||
|         country: data.country, | ||||
|         countryCode: data.countryCode, | ||||
|         loc: data.lat && data.lon ? `${data.lat},${data.lon}` : undefined, | ||||
|         timezone: data.timezone, | ||||
|         isp: data.isp, | ||||
|         org: data.org, | ||||
|         as: data.as, | ||||
|         proxy: data.proxy, | ||||
|         hosting: data.hosting, | ||||
|       }; | ||||
|  | ||||
|       setIpInfo(ipData); | ||||
|       // ipinfo.io 返回格式已经符合 IPInfo 接口 | ||||
|       setIpInfo(data); | ||||
|       toast.success("Query successful"); | ||||
|     } catch (error) { | ||||
|       if (error instanceof Error) { | ||||
| @@ -146,14 +126,18 @@ const Tool: FC = () => { | ||||
|   const getRiskLevel = () => { | ||||
|     if (!ipInfo) return null; | ||||
|      | ||||
|     if (ipInfo.proxy || ipInfo.hosting) { | ||||
|     // ipinfo.io 通过 org 字段可以简单判断是否为托管IP | ||||
|     const orgLower = ipInfo.org?.toLowerCase() || ""; | ||||
|     const isHosting = orgLower.includes("hosting") ||  | ||||
|                      orgLower.includes("datacenter") ||  | ||||
|                      orgLower.includes("cloud") || | ||||
|                      orgLower.includes("server"); | ||||
|      | ||||
|     if (isHosting) { | ||||
|       return { | ||||
|         level: "High", | ||||
|         color: "text-red-500", | ||||
|         reasons: [ | ||||
|           ipInfo.proxy && "Proxy/VPN detected", | ||||
|           ipInfo.hosting && "Hosting/Datacenter IP", | ||||
|         ].filter(Boolean), | ||||
|         level: "Medium", | ||||
|         color: "text-yellow-500", | ||||
|         reasons: ["Possible Hosting/Datacenter IP"], | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -1,22 +1,94 @@ | ||||
| import { type FC } from "react"; | ||||
|  | ||||
| import { type FC, useState } from "react"; | ||||
| import { RefreshCw, Copy } from "lucide-react"; | ||||
| import * as uuid from 'uuid' | ||||
| import { nanoid } from 'nanoid' | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { toast } from "sonner"; | ||||
|  | ||||
| interface IDGeneratorProps { | ||||
|   label: string; | ||||
|   value: string; | ||||
|   onRegenerate: () => void; | ||||
| } | ||||
|  | ||||
| const IDGenerator: FC<IDGeneratorProps> = ({ label, value, onRegenerate }) => { | ||||
|   const copyToClipboard = async () => { | ||||
|     try { | ||||
|       await navigator.clipboard.writeText(value); | ||||
|       toast(`${label} has been copied to clipboard`); | ||||
|     } catch (err) { | ||||
|       toast.error("Copy failed"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-2"> | ||||
|       <label className="font-medium">{label}</label> | ||||
|       <div className="flex items-center gap-2"> | ||||
|         <span className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm break-all max-w-[400px]"> | ||||
|           {value} | ||||
|         </span> | ||||
|         <Button | ||||
|           size="icon" | ||||
|           variant="outline" | ||||
|           onClick={onRegenerate} | ||||
|           title="Regenerate" | ||||
|         > | ||||
|           <RefreshCw className="h-4 w-4" /> | ||||
|         </Button> | ||||
|         <Button | ||||
|           size="icon" | ||||
|           variant="outline" | ||||
|           onClick={copyToClipboard} | ||||
|           title="Copy" | ||||
|         > | ||||
|           <Copy className="h-4 w-4" /> | ||||
|         </Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [uuidV1, setUuidV1] = useState(() => uuid.v1()); | ||||
|   const [uuidV4, setUuidV4] = useState(() => uuid.v4()); | ||||
|   const [uuidV6, setUuidV6] = useState(() => uuid.v6()); | ||||
|   const [uuidV7, setUuidV7] = useState(() => uuid.v7()); | ||||
|   const [nanoId, setNanoId] = useState(() => nanoid()); | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4"> | ||||
|       <span className="text-sm text-muted-foreground">Refresh the page to generate new UUID</span> | ||||
|       <label>UUID Version 1</label> | ||||
|       <span>{uuid.v1()}</span> | ||||
|       <label>UUID Version 4</label> | ||||
|       <span>{uuid.v4()}</span> | ||||
|       <label>UUID Version 6</label> | ||||
|       <span>{uuid.v6()}</span> | ||||
|       <label>UUID Version 7</label> | ||||
|       <span>{uuid.v7()}</span> | ||||
|       <label>Nano ID</label> | ||||
|       <span>{nanoid()}</span> | ||||
|       <span className="text-sm text-muted-foreground">Click the refresh button to regenerate the corresponding ID</span> | ||||
|        | ||||
|       <IDGenerator  | ||||
|         label="UUID Version 1"  | ||||
|         value={uuidV1}  | ||||
|         onRegenerate={() => setUuidV1(uuid.v1())}  | ||||
|       /> | ||||
|        | ||||
|       <IDGenerator  | ||||
|         label="UUID Version 4"  | ||||
|         value={uuidV4}  | ||||
|         onRegenerate={() => setUuidV4(uuid.v4())}  | ||||
|       /> | ||||
|        | ||||
|       <IDGenerator  | ||||
|         label="UUID Version 6"  | ||||
|         value={uuidV6}  | ||||
|         onRegenerate={() => setUuidV6(uuid.v6())}  | ||||
|       /> | ||||
|        | ||||
|       <IDGenerator  | ||||
|         label="UUID Version 7"  | ||||
|         value={uuidV7}  | ||||
|         onRegenerate={() => setUuidV7(uuid.v7())}  | ||||
|       /> | ||||
|        | ||||
|       <IDGenerator  | ||||
|         label="Nano ID"  | ||||
|         value={nanoId}  | ||||
|         onRegenerate={() => setNanoId(nanoid())}  | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof badgeVariants> & { asChild?: boolean }) { | ||||
|   const Comp = asChild ? Slot : "span" | ||||
|  | ||||
|   return ( | ||||
|     <Comp | ||||
|       data-slot="badge" | ||||
|       className={cn(badgeVariants({ variant }), className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { Badge, badgeVariants } | ||||
							
								
								
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import * as React from "react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| function Card({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card" | ||||
|       className={cn( | ||||
|         "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-header" | ||||
|       className={cn( | ||||
|         "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardTitle({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-title" | ||||
|       className={cn("leading-none font-semibold", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardDescription({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-description" | ||||
|       className={cn("text-muted-foreground text-sm", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardAction({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-action" | ||||
|       className={cn( | ||||
|         "col-start-2 row-span-2 row-start-1 self-start justify-self-end", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardContent({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-content" | ||||
|       className={cn("px-6", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CardFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="card-footer" | ||||
|       className={cn("flex items-center px-6 [.border-t]:pt-6", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Card, | ||||
|   CardHeader, | ||||
|   CardFooter, | ||||
|   CardTitle, | ||||
|   CardAction, | ||||
|   CardDescription, | ||||
|   CardContent, | ||||
| } | ||||
							
								
								
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof CommandPrimitive>) { | ||||
|   return ( | ||||
|     <CommandPrimitive | ||||
|       data-slot="command" | ||||
|       className={cn( | ||||
|         "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandDialog({ | ||||
|   title = "Command Palette", | ||||
|   description = "Search for a command to run...", | ||||
|   children, | ||||
|   className, | ||||
|   showCloseButton = true, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Dialog> & { | ||||
|   title?: string | ||||
|   description?: string | ||||
|   className?: string | ||||
|   showCloseButton?: boolean | ||||
| }) { | ||||
|   return ( | ||||
|     <Dialog {...props}> | ||||
|       <DialogHeader className="sr-only"> | ||||
|         <DialogTitle>{title}</DialogTitle> | ||||
|         <DialogDescription>{description}</DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <DialogContent | ||||
|         className={cn("overflow-hidden p-0", className)} | ||||
|         showCloseButton={showCloseButton} | ||||
|       > | ||||
|         <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | ||||
|           {children} | ||||
|         </Command> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandInput({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Input>) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="command-input-wrapper" | ||||
|       className="flex h-9 items-center gap-2 border-b px-3" | ||||
|     > | ||||
|       <SearchIcon className="size-4 shrink-0 opacity-50" /> | ||||
|       <CommandPrimitive.Input | ||||
|         data-slot="command-input" | ||||
|         className={cn( | ||||
|           "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandList({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.List>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.List | ||||
|       data-slot="command-list" | ||||
|       className={cn( | ||||
|         "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandEmpty({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Empty>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Empty | ||||
|       data-slot="command-empty" | ||||
|       className="py-6 text-center text-sm" | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandGroup({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Group>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Group | ||||
|       data-slot="command-group" | ||||
|       className={cn( | ||||
|         "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandSeparator({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Separator>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Separator | ||||
|       data-slot="command-separator" | ||||
|       className={cn("bg-border -mx-1 h-px", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandItem({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Item>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Item | ||||
|       data-slot="command-item" | ||||
|       className={cn( | ||||
|         "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandShortcut({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"span">) { | ||||
|   return ( | ||||
|     <span | ||||
|       data-slot="command-shortcut" | ||||
|       className={cn( | ||||
|         "text-muted-foreground ml-auto text-xs tracking-widest", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Command, | ||||
|   CommandDialog, | ||||
|   CommandInput, | ||||
|   CommandList, | ||||
|   CommandEmpty, | ||||
|   CommandGroup, | ||||
|   CommandItem, | ||||
|   CommandShortcut, | ||||
|   CommandSeparator, | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof DialogPrimitive.Root>) { | ||||
|   return <DialogPrimitive.Root data-slot="dialog" {...props} /> | ||||
| } | ||||
|  | ||||
| function DialogTrigger({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { | ||||
|   return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> | ||||
| } | ||||
|  | ||||
| function DialogPortal({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Portal>) { | ||||
|   return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> | ||||
| } | ||||
|  | ||||
| function DialogClose({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Close>) { | ||||
|   return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> | ||||
| } | ||||
|  | ||||
| function DialogOverlay({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { | ||||
|   return ( | ||||
|     <DialogPrimitive.Overlay | ||||
|       data-slot="dialog-overlay" | ||||
|       className={cn( | ||||
|         "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function DialogContent({ | ||||
|   className, | ||||
|   children, | ||||
|   showCloseButton = true, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Content> & { | ||||
|   showCloseButton?: boolean | ||||
| }) { | ||||
|   return ( | ||||
|     <DialogPortal data-slot="dialog-portal"> | ||||
|       <DialogOverlay /> | ||||
|       <DialogPrimitive.Content | ||||
|         data-slot="dialog-content" | ||||
|         className={cn( | ||||
|           "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       > | ||||
|         {children} | ||||
|         {showCloseButton && ( | ||||
|           <DialogPrimitive.Close | ||||
|             data-slot="dialog-close" | ||||
|             className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" | ||||
|           > | ||||
|             <XIcon /> | ||||
|             <span className="sr-only">Close</span> | ||||
|           </DialogPrimitive.Close> | ||||
|         )} | ||||
|       </DialogPrimitive.Content> | ||||
|     </DialogPortal> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="dialog-header" | ||||
|       className={cn("flex flex-col gap-2 text-center sm:text-left", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="dialog-footer" | ||||
|       className={cn( | ||||
|         "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function DialogTitle({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Title>) { | ||||
|   return ( | ||||
|     <DialogPrimitive.Title | ||||
|       data-slot="dialog-title" | ||||
|       className={cn("text-lg leading-none font-semibold", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function DialogDescription({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DialogPrimitive.Description>) { | ||||
|   return ( | ||||
|     <DialogPrimitive.Description | ||||
|       data-slot="dialog-description" | ||||
|       className={cn("text-muted-foreground text-sm", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Dialog, | ||||
|   DialogClose, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogFooter, | ||||
|   DialogHeader, | ||||
|   DialogOverlay, | ||||
|   DialogPortal, | ||||
|   DialogTitle, | ||||
|   DialogTrigger, | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof LabelPrimitive.Root>) { | ||||
|   return ( | ||||
|     <LabelPrimitive.Root | ||||
|       data-slot="label" | ||||
|       className={cn( | ||||
|         "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { Label } | ||||
							
								
								
									
										46
									
								
								src/components/ui/popover.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/popover.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<typeof PopoverPrimitive.Root>) { | ||||
|   return <PopoverPrimitive.Root data-slot="popover" {...props} /> | ||||
| } | ||||
|  | ||||
| function PopoverTrigger({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { | ||||
|   return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> | ||||
| } | ||||
|  | ||||
| function PopoverContent({ | ||||
|   className, | ||||
|   align = "center", | ||||
|   sideOffset = 4, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof PopoverPrimitive.Content>) { | ||||
|   return ( | ||||
|     <PopoverPrimitive.Portal> | ||||
|       <PopoverPrimitive.Content | ||||
|         data-slot="popover-content" | ||||
|         align={align} | ||||
|         sideOffset={sideOffset} | ||||
|         className={cn( | ||||
|           "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </PopoverPrimitive.Portal> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function PopoverAnchor({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { | ||||
|   return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> | ||||
| } | ||||
|  | ||||
| export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } | ||||
							
								
								
									
										96
									
								
								src/hooks/use-seo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/hooks/use-seo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| import { useEffect } from 'react'; | ||||
| import { useLocation } from 'react-router-dom'; | ||||
|  | ||||
| interface UseSEOOptions { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   baseUrl?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * SEO Hook - 动态更新页面 SEO 元数据 | ||||
|  *  | ||||
|  * @param options - SEO 配置选项 | ||||
|  * @param options.title - 页面标题(可选) | ||||
|  * @param options.description - 页面描述(可选) | ||||
|  * @param options.baseUrl - 网站基础 URL,默认为 https://litek.typist.cc | ||||
|  *  | ||||
|  * @example | ||||
|  * ```tsx | ||||
|  * // 在组件中使用 | ||||
|  * useSEO({ | ||||
|  *   title: 'UUID Generator', | ||||
|  *   description: 'Free online UUID generator tool' | ||||
|  * }); | ||||
|  * ``` | ||||
|  */ | ||||
| export const useSEO = (options: UseSEOOptions = {}) => { | ||||
|   const location = useLocation(); | ||||
|   const {  | ||||
|     title,  | ||||
|     description,  | ||||
|     baseUrl = 'https://litek.typist.cc'  | ||||
|   } = options; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // 构建当前页面的完整 URL | ||||
|     const canonicalUrl = `${baseUrl}${location.pathname}`; | ||||
|      | ||||
|     // 更新或创建 canonical 链接 | ||||
|     let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement; | ||||
|     if (!canonical) { | ||||
|       canonical = document.createElement('link'); | ||||
|       canonical.setAttribute('rel', 'canonical'); | ||||
|       document.head.appendChild(canonical); | ||||
|     } | ||||
|     canonical.setAttribute('href', canonicalUrl); | ||||
|  | ||||
|     // 更新页面标题 | ||||
|     if (title) { | ||||
|       document.title = `${title} - Lite Kit`; | ||||
|     } | ||||
|  | ||||
|     // 更新 meta description | ||||
|     if (description) { | ||||
|       let metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement; | ||||
|       if (metaDescription) { | ||||
|         metaDescription.setAttribute('content', description); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // 更新 Open Graph URL | ||||
|     let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement; | ||||
|     if (ogUrl) { | ||||
|       ogUrl.setAttribute('content', canonicalUrl); | ||||
|     } | ||||
|  | ||||
|     // 更新 Open Graph Title | ||||
|     if (title) { | ||||
|       let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement; | ||||
|       if (ogTitle) { | ||||
|         ogTitle.setAttribute('content', `${title} - Lite Kit`); | ||||
|       } | ||||
|  | ||||
|       // 更新 Twitter Card Title | ||||
|       let twitterTitle = document.querySelector('meta[name="twitter:title"]') as HTMLMetaElement; | ||||
|       if (twitterTitle) { | ||||
|         twitterTitle.setAttribute('content', `${title} - Lite Kit`); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // 更新 Open Graph Description | ||||
|     if (description) { | ||||
|       let ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement; | ||||
|       if (ogDescription) { | ||||
|         ogDescription.setAttribute('content', description); | ||||
|       } | ||||
|  | ||||
|       // 更新 Twitter Card Description | ||||
|       let twitterDescription = document.querySelector('meta[name="twitter:description"]') as HTMLMetaElement; | ||||
|       if (twitterDescription) { | ||||
|         twitterDescription.setAttribute('content', description); | ||||
|       } | ||||
|     } | ||||
|   }, [location.pathname, title, description, baseUrl]); | ||||
| }; | ||||
|  | ||||
| @@ -116,6 +116,7 @@ | ||||
|   } | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|     font-family: 'Roboto Mono', 'Noto Sans SC', 'SF Mono', Consolas, monospace; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,20 +4,28 @@ import { Outlet } from "react-router-dom"; | ||||
| import { ThemeProvider } from "@/components/theme/provider" | ||||
| import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" | ||||
| import { AppSidebar } from "@/components/sidebar"; | ||||
| import { ModeToggle } from "@/components/theme/toggle"; | ||||
|  | ||||
| export const Layout: FC = () => ( | ||||
|   <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> | ||||
|     <SidebarProvider> | ||||
|       <AppSidebar /> | ||||
|       <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> | ||||
|         <nav className="flex items-center justify-between"> | ||||
|           <SidebarTrigger className="size-10" /> | ||||
|           <div role="actions" /> | ||||
|         </nav> | ||||
|         <main className="flex-1 overflow-auto p-4 overflow-hidden"> | ||||
|           <Outlet /> | ||||
|         </main> | ||||
|       </div> | ||||
|     </SidebarProvider> | ||||
|   </ThemeProvider> | ||||
| ); | ||||
| import { useSEO } from "@/hooks/use-seo"; | ||||
|  | ||||
| export const Layout: FC = () => { | ||||
|   // 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据 | ||||
|   useSEO(); | ||||
|  | ||||
|   return ( | ||||
|     <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> | ||||
|       <SidebarProvider> | ||||
|         <AppSidebar /> | ||||
|         <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> | ||||
|           <nav className="flex items-center justify-between"> | ||||
|             <SidebarTrigger className="size-10" /> | ||||
|             <ModeToggle /> | ||||
|           </nav> | ||||
|           <main className="flex-1 overflow-auto p-4 overflow-hidden"> | ||||
|             <Outlet /> | ||||
|           </main> | ||||
|         </div> | ||||
|       </SidebarProvider> | ||||
|     </ThemeProvider> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										41
									
								
								src/main.tsx
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								src/main.tsx
									
									
									
									
									
								
							| @@ -2,7 +2,7 @@ import { StrictMode } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | ||||
|  | ||||
| import { Toaster } from '@/components/ui/sonner' | ||||
|  | ||||
| import { toast } from 'sonner' | ||||
|  | ||||
| import './index.css' | ||||
| import { AppRouter } from './router' | ||||
| @@ -13,3 +13,42 @@ createRoot(document.getElementById('root')!).render( | ||||
|     <Toaster /> | ||||
|   </StrictMode> | ||||
| ) | ||||
|  | ||||
| // 注册 Service Worker | ||||
| if ('serviceWorker' in navigator && import.meta.env.PROD) { | ||||
|   import('workbox-window').then(({ Workbox }) => { | ||||
|     const wb = new Workbox('/sw.js') | ||||
|      | ||||
|     // 检测到新版本时,在后台下载完成后显示通知 | ||||
|     wb.addEventListener('waiting', () => { | ||||
|       // 显示更新通知,右上角弹窗 | ||||
|       toast.info('found new version', { | ||||
|         description: 'new content available, click update to get the latest version', | ||||
|         duration: Infinity, // 持续显示,直到用户操作 | ||||
|         action: { | ||||
|           label: 'update now', | ||||
|           onClick: () => { | ||||
|             // 用户点击更新按钮 | ||||
|             wb.messageSkipWaiting() | ||||
|           } | ||||
|         }, | ||||
|         cancel: { | ||||
|           label: 'later', | ||||
|           onClick: () => { | ||||
|             // 用户选择稍后更新,关闭通知 | ||||
|             // 新版本会在下次手动刷新时自动激活 | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|  | ||||
|     // 当新的 Service Worker 接管页面时,刷新页面 | ||||
|     wb.addEventListener('controlling', () => { | ||||
|       window.location.reload() | ||||
|     }) | ||||
|  | ||||
|     wb.register() | ||||
|   }).catch((error) => { | ||||
|     console.error('Failed to register service worker:', error) | ||||
|   }) | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { Suspense, createElement } from "react"; | ||||
| import { | ||||
|   createBrowserRouter, | ||||
|   redirect, | ||||
| @@ -8,6 +9,19 @@ import { | ||||
| import { tools, type Tool } from "@/components/tool"; | ||||
| import { Layout } from "./layout"; | ||||
|  | ||||
| // 加载中的占位组件 | ||||
| const LoadingFallback = () => ( | ||||
|   <div className="flex items-center justify-center h-full"> | ||||
|     <div className="text-center flex flex-col items-center gap-3"> | ||||
|       <div className="relative"> | ||||
|         <div className="h-12 w-12 rounded-full border-4 border-muted"></div> | ||||
|         <div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div> | ||||
|       </div> | ||||
|       <p className="text-sm text-muted-foreground font-medium">Loading...</p> | ||||
|     </div> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const buildToolRoutes = (tools: Tool[]): RouteObject[] => { | ||||
|   return tools.map((tool) => { | ||||
|     const route: RouteObject = { | ||||
| @@ -15,7 +29,12 @@ const buildToolRoutes = (tools: Tool[]): RouteObject[] => { | ||||
|     }; | ||||
|  | ||||
|     if (tool.component) { | ||||
|       route.element = tool.component; | ||||
|       // 使用 Suspense 包裹懒加载组件 | ||||
|       route.element = ( | ||||
|         <Suspense fallback={<LoadingFallback />}> | ||||
|           {createElement(tool.component)} | ||||
|         </Suspense> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (tool.children && tool.children.length > 0) { | ||||
|   | ||||
							
								
								
									
										3
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /// <reference types="vite/client" /> | ||||
| /// <reference types="vite-plugin-pwa/client" /> | ||||
|  | ||||
							
								
								
									
										121
									
								
								vite.config.ts
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								vite.config.ts
									
									
									
									
									
								
							| @@ -2,13 +2,132 @@ import path from "path" | ||||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react' | ||||
| import tailwindcss from "@tailwindcss/vite" | ||||
| import { VitePWA } from 'vite-plugin-pwa' | ||||
|  | ||||
| // https://vite.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [react(), tailwindcss()], | ||||
|   plugins: [ | ||||
|     react(),  | ||||
|     tailwindcss(), | ||||
|     VitePWA({ | ||||
|       registerType: 'autoUpdate', | ||||
|       includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'], | ||||
|       manifest: { | ||||
|         name: 'Lite Kit - Lightweight Online Tools', | ||||
|         short_name: 'Lite Kit', | ||||
|         description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more', | ||||
|         theme_color: '#000000', | ||||
|         background_color: '#000000', | ||||
|         display: 'standalone', | ||||
|         icons: [ | ||||
|           { | ||||
|             src: '/lite.svg', | ||||
|             type: 'image/svg+xml', | ||||
|             sizes: 'any' | ||||
|           } | ||||
|         ] | ||||
|       }, | ||||
|       workbox: { | ||||
|         globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], | ||||
|         runtimeCaching: [ | ||||
|           { | ||||
|             urlPattern: /^https:\/\/ipinfo\.io\/.*/i, | ||||
|             handler: "NetworkFirst", | ||||
|             options: { | ||||
|               cacheName: 'ipinfo-cache', | ||||
|               expiration: { | ||||
|                 maxEntries: 10, | ||||
|                 maxAgeSeconds: 60 * 60 // 延长到 1 小时 | ||||
|               }, | ||||
|               cacheableResponse: { | ||||
|                 statuses: [0, 200] | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             // Google Fonts 样式表缓存 | ||||
|             urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, | ||||
|             handler: 'StaleWhileRevalidate', | ||||
|             options: { | ||||
|               cacheName: 'google-fonts-stylesheets', | ||||
|               expiration: { | ||||
|                 maxEntries: 10, | ||||
|                 maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年 | ||||
|               }, | ||||
|               cacheableResponse: { | ||||
|                 statuses: [0, 200] | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             // Google Fonts 字体文件缓存 | ||||
|             urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, | ||||
|             handler: 'CacheFirst', | ||||
|             options: { | ||||
|               cacheName: 'google-fonts-webfonts', | ||||
|               expiration: { | ||||
|                 maxEntries: 30, | ||||
|                 maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年 | ||||
|               }, | ||||
|               cacheableResponse: { | ||||
|                 statuses: [0, 200] | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         cleanupOutdatedCaches: true, | ||||
|         clientsClaim: true,   // 新 SW 激活后立即接管 | ||||
|         skipWaiting: false    // 不自动跳过等待,需要手动触发 | ||||
|       } | ||||
|     }) | ||||
|   ], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       "@": path.resolve(__dirname, "./src"), | ||||
|     }, | ||||
|   }, | ||||
|   build: { | ||||
|     rollupOptions: { | ||||
|       output: { | ||||
|         manualChunks: (id) => { | ||||
|           // React 核心拆分得更细 | ||||
|           if (id.includes('node_modules/react/') && !id.includes('node_modules/react-dom')) { | ||||
|             return 'react-core'; | ||||
|           } | ||||
|           if (id.includes('node_modules/react-dom/')) { | ||||
|             return 'react-dom'; | ||||
|           } | ||||
|           if (id.includes('node_modules/react-router-dom')) { | ||||
|             return 'react-router'; | ||||
|           } | ||||
|           // Radix UI组件 | ||||
|           if (id.includes('node_modules/@radix-ui')) { | ||||
|             return 'ui-vendor'; | ||||
|           } | ||||
|           // 图标库 | ||||
|           if (id.includes('node_modules/lucide-react')) { | ||||
|             return 'icons'; | ||||
|           } | ||||
|           // 其他工具库 | ||||
|           if (id.includes('node_modules/')) { | ||||
|             return 'vendor'; | ||||
|           } | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     // 启用更激进的压缩 | ||||
|     minify: 'terser', | ||||
|     terserOptions: { | ||||
|       compress: { | ||||
|         drop_console: true, | ||||
|         drop_debugger: true, | ||||
|         pure_funcs: ['console.log'], | ||||
|         // 移除未使用的代码 | ||||
|         unused: true, | ||||
|         // 移除死代码 | ||||
|         dead_code: true, | ||||
|       }, | ||||
|     }, | ||||
|     chunkSizeWarningLimit: 500, | ||||
|   }, | ||||
| }) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user