Compare commits
	
		
			26 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 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 | 
							
								
								
									
										171
									
								
								SEO-README.md
									
									
									
									
									
								
							
							
						
						
									
										171
									
								
								SEO-README.md
									
									
									
									
									
								
							| @@ -1,171 +0,0 @@ | ||||
| # SEO 优化说明 | ||||
|  | ||||
| 本项目已完成基础 SEO 优化,以下是已实施的改动。 | ||||
|  | ||||
| ## 已完成的优化项 | ||||
|  | ||||
| ### 1. HTML Meta 标签优化 ✅ | ||||
|  | ||||
| 在 `index.html` 中添加了完整的 SEO 元数据: | ||||
|  | ||||
| - **基础 Meta 标签** | ||||
|   - title, description, keywords | ||||
|   - author, theme-color | ||||
|   - canonical URL | ||||
|    | ||||
| - **Open Graph 标签**(社交媒体分享优化) | ||||
|   - og:type, og:url, og:title | ||||
|   - og:description, og:image, og:site_name | ||||
|    | ||||
| - **Twitter Card 标签** | ||||
|   - twitter:card, twitter:title | ||||
|   - twitter:description, twitter:image | ||||
|  | ||||
| - **结构化数据**(JSON-LD) | ||||
|   - Schema.org WebSite 类型标记 | ||||
|   - 提升搜索引擎理解能力 | ||||
|  | ||||
| ### 2. SEO 配置文件 ✅ | ||||
|  | ||||
| - **`public/robots.txt`** - 搜索引擎爬虫规则 | ||||
|   - 允许所有爬虫访问 | ||||
|   - 指定 sitemap 位置 | ||||
|    | ||||
| - **`public/sitemap.xml`** - 站点地图(自动生成) | ||||
|   - 包含所有工具页面 URL | ||||
|   - 设置更新频率和优先级 | ||||
|   - 通过 `scripts/generate-sitemap.ts` 自动生成 | ||||
|    | ||||
| - **`public/manifest.json`** - PWA 配置 | ||||
|   - 支持渐进式 Web 应用 | ||||
|   - 改善移动端体验 | ||||
|  | ||||
| ### 3. 构建流程优化 ✅ | ||||
|  | ||||
| - **`package.json`** | ||||
|   - 构建时自动生成 sitemap | ||||
|   - 添加 tsx 依赖用于运行生成脚本 | ||||
|  | ||||
| ## 文件清单 | ||||
|  | ||||
| ### 新增文件 | ||||
| ``` | ||||
| public/ | ||||
| ├── robots.txt              # 爬虫规则 | ||||
| ├── sitemap.xml            # 站点地图(自动生成) | ||||
| └── manifest.json          # PWA 配置 | ||||
|  | ||||
| scripts/ | ||||
| └── generate-sitemap.ts    # Sitemap 生成脚本 | ||||
| ``` | ||||
|  | ||||
| ### 修改文件 | ||||
| ``` | ||||
| index.html                 # 添加 SEO meta 标签和结构化数据 | ||||
| package.json               # 添加 sitemap 生成命令 | ||||
| ``` | ||||
|  | ||||
| ## 使用说明 | ||||
|  | ||||
| ### 开发 | ||||
| ```bash | ||||
| pnpm install               # 安装依赖 | ||||
| pnpm dev                   # 启动开发服务器 | ||||
| ``` | ||||
|  | ||||
| ### 构建 | ||||
| ```bash | ||||
| pnpm run build             # 构建项目(自动生成 sitemap) | ||||
| pnpm run generate:sitemap  # 单独生成 sitemap | ||||
| ``` | ||||
|  | ||||
| ### 部署前检查 | ||||
|  | ||||
| ⚠️ **重要:部署前必须更新配置文件中的域名!** | ||||
|  | ||||
| 需要将以下文件中的 `https://litek.typist.cc` 替换为你的实际域名: | ||||
|  | ||||
| 1. **`index.html`** | ||||
|    - canonical URL | ||||
|    - Open Graph URL 和 image | ||||
|    - Twitter Card image | ||||
|    - Structured Data URL | ||||
|  | ||||
| 2. **`public/robots.txt`** | ||||
|    - Sitemap URL | ||||
|  | ||||
| 3. **`scripts/generate-sitemap.ts`** | ||||
|    - BASE_URL 常量 | ||||
|  | ||||
| 更新后重新构建: | ||||
| ```bash | ||||
| pnpm run build | ||||
| ``` | ||||
|  | ||||
| ## SEO 验证 | ||||
|  | ||||
| ### 部署后验证 | ||||
|  | ||||
| 1. **提交 sitemap 到 Google Search Console** | ||||
|    - 访问 https://search.google.com/search-console | ||||
|    - 添加网站资源 | ||||
|    - 提交 sitemap: `https://你的域名/sitemap.xml` | ||||
|  | ||||
| 2. **测试结构化数据** | ||||
|    - 访问 https://validator.schema.org/ | ||||
|    - 输入网站 URL | ||||
|    - 检查是否有错误 | ||||
|  | ||||
| 3. **测试 Open Graph 预览** | ||||
|    - Facebook: https://developers.facebook.com/tools/debug/ | ||||
|    - Twitter: https://cards-dev.twitter.com/validator | ||||
|  | ||||
| 4. **性能测试** | ||||
|    - Google PageSpeed Insights: https://pagespeed.web.dev/ | ||||
|    - 目标分数: SEO > 95 | ||||
|  | ||||
| ## 可访问的 URL | ||||
|  | ||||
| 部署后,以下 URL 应该可以正常访问: | ||||
|  | ||||
| - `https://你的域名/` - 主页 | ||||
| - `https://你的域名/robots.txt` - 爬虫规则 | ||||
| - `https://你的域名/sitemap.xml` - 站点地图 | ||||
| - `https://你的域名/manifest.json` - PWA 配置 | ||||
| - `https://你的域名/tool/uuid` - UUID 工具 | ||||
| - `https://你的域名/tool/json` - JSON 工具 | ||||
| - `https://你的域名/tool/base64` - Base64 工具 | ||||
| - `https://你的域名/tool/network/dns` - DNS 工具 | ||||
| - ... 其他工具页面 | ||||
|  | ||||
| ## 添加新工具时更新 SEO | ||||
|  | ||||
| 当添加新工具时,需要更新 `scripts/generate-sitemap.ts`: | ||||
|  | ||||
| ```typescript | ||||
| const tools = [ | ||||
|   // 现有工具... | ||||
|   { path: '/tool/你的新工具', priority: '0.9' }, | ||||
| ]; | ||||
| ``` | ||||
|  | ||||
| 然后重新构建项目。 | ||||
|  | ||||
| ## SEO 效果预期 | ||||
|  | ||||
| - **1-2 周**:搜索引擎开始索引页面 | ||||
| - **1-2 月**:主要关键词开始有排名 | ||||
| - **3-6 月**:稳定的搜索排名和自然流量增长 | ||||
|  | ||||
| ## 技术栈 | ||||
|  | ||||
| - React 19 + TypeScript | ||||
| - Vite (Rolldown) | ||||
| - React Router v7 | ||||
| - Radix UI + Tailwind CSS 4 | ||||
| - Nginx | ||||
|  | ||||
| ## 联系方式 | ||||
|  | ||||
| 需要更多工具或有建议?联系:litek@mail.typist.cc | ||||
|  | ||||
							
								
								
									
										13
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								index.html
									
									
									
									
									
								
							| @@ -14,6 +14,15 @@ | ||||
|     <!-- 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/" /> | ||||
| @@ -55,5 +64,9 @@ | ||||
|   <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> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|   "name": "litek", | ||||
|   "private": true, | ||||
|   "version": "0.0.17", | ||||
|   "version": "0.0.29", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
| @@ -44,11 +44,14 @@ | ||||
|     "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": { | ||||
|   | ||||
							
								
								
									
										2811
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2811
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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,11 +114,7 @@ export const AppSidebar = () => { | ||||
|           </SidebarGroupContent> | ||||
|         </SidebarGroup> | ||||
|       </SidebarContent> | ||||
|       <SidebarFooter className="flex flex-row justify-between items-center gap-2"> | ||||
|         <SidebarMenuButton variant="outline" asChild> | ||||
|           <a href="mailto:litek@mail.typist.cc">need more tools?</a> | ||||
|         </SidebarMenuButton> | ||||
|       </SidebarFooter> | ||||
|       <SidebarFooter className="flex flex-col gap-2" /> | ||||
|     </Sidebar> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,17 +1,22 @@ | ||||
| import type { ReactNode } from 'react'; | ||||
| import { lazy, type ReactNode, type ComponentType } from 'react'; | ||||
| import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } 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 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 +26,21 @@ 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: "network", | ||||
| @@ -48,35 +53,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'; | ||||
|  | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -116,6 +116,7 @@ | ||||
|   } | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|     font-family: 'Roboto Mono', 'Noto Sans SC', 'SF Mono', Consolas, monospace; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										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