Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca7e11835 | ||
|
|
782de6e38a | ||
|
|
7646830194 | ||
|
|
59f998e8e3 | ||
|
|
55207beff5 | ||
|
|
e98a344b95 | ||
|
|
d553c3e04c | ||
|
|
e2da2758cc | ||
|
|
3ab70498e6 | ||
|
|
a5ef1a1e70 | ||
|
|
297000f208 | ||
|
|
04fbf12e07 | ||
| a9a6354b2d | |||
|
|
109139a42e | ||
|
|
b5a811e5ee | ||
|
|
b3adfe5c8f | ||
|
|
8eda2eae99 | ||
|
|
99673913a6 | ||
|
|
83e48e3485 | ||
|
|
8607591871 | ||
|
|
24c154a759 | ||
|
|
edf87370d9 |
@@ -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
|
||||
|
||||
|
||||
171
SEO-README.md
Normal file
171
SEO-README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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
|
||||
|
||||
54
index.html
54
index.html
@@ -2,9 +2,59 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
11
package.json
11
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "litek",
|
||||
"private": true,
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.20",
|
||||
"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",
|
||||
@@ -43,17 +44,21 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3105
pnpm-lock.yaml
generated
3105
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,19 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<style>
|
||||
/* 默认浅色模式使用黑色 */
|
||||
path {
|
||||
fill: #000000;
|
||||
}
|
||||
/* 暗色模式使用白色 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: #ffffff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M12.6089 6.12601C12.6785 5.85878 12.5183 5.58573 12.251 5.51614C11.9838 5.44655 11.7108 5.60676 11.6412 5.87399L10.0842 11.8528L7.85646 12.5211C7.59196 12.6005 7.44187 12.8792 7.52122 13.1437C7.60057 13.4082 7.87931 13.5583 8.14381 13.479L9.78926 12.9853L8.51617 17.874C8.47715 18.0238 8.50974 18.1833 8.60443 18.3058C8.69911 18.4283 8.8452 18.5 9.00003 18.5H16C16.2762 18.5 16.5 18.2761 16.5 18C16.5 17.7239 16.2762 17.5 16 17.5H9.64691L10.9102 12.6491L13.1438 11.979C13.4083 11.8996 13.5584 11.6209 13.479 11.3564C13.3997 11.0919 13.121 10.9418 12.8565 11.0211L11.2051 11.5165L12.6089 6.12601Z"/> </g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,10 +115,9 @@ export const AppSidebar = () => {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="flex flex-row justify-between items-center gap-2">
|
||||
<Button variant="link">
|
||||
<SidebarMenuButton variant="outline" asChild>
|
||||
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
|
||||
</Button>
|
||||
<ModeToggle />
|
||||
</SidebarMenuButton>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi } from 'lucide-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 } 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,28 +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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +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';
|
||||
|
||||
296
src/components/tool/network/ipquery.tsx
Normal file
296
src/components/tool/network/ipquery.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useState, type FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface IPInfo {
|
||||
ip: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
loc?: string;
|
||||
org?: string;
|
||||
timezone?: string;
|
||||
isp?: string;
|
||||
as?: string;
|
||||
proxy?: boolean;
|
||||
hosting?: boolean;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const Tool: FC = () => {
|
||||
const [ip, setIp] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null);
|
||||
const [queryTime, setQueryTime] = useState<number>(0);
|
||||
|
||||
const isValidIP = (ip: string): boolean => {
|
||||
// IPv4 正则
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
// IPv6 正则 (简化版)
|
||||
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||
|
||||
if (ipv4Regex.test(ip)) {
|
||||
const parts = ip.split('.');
|
||||
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
|
||||
}
|
||||
|
||||
return ipv6Regex.test(ip);
|
||||
};
|
||||
|
||||
const queryCurrentIP = async () => {
|
||||
setLoading(true);
|
||||
setIpInfo(null);
|
||||
setQueryTime(0);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 使用 ipinfo.io 查询当前IP (免费,无需密钥)
|
||||
const response = await fetch("https://ipinfo.io/json");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const endTime = performance.now();
|
||||
|
||||
setQueryTime(endTime - startTime);
|
||||
setIpInfo(data);
|
||||
setIp(data.ip);
|
||||
toast.success("Successfully queried current IP");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Query failed: ${error.message}`);
|
||||
} else {
|
||||
toast.error("Query failed");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const queryIP = async () => {
|
||||
if (!ip.trim()) {
|
||||
toast.error("Please enter an IP address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidIP(ip.trim())) {
|
||||
toast.error("Invalid IP address format");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setIpInfo(null);
|
||||
setQueryTime(0);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 使用 ipinfo.io (免费,稳定可靠)
|
||||
const response = await fetch(`https://ipinfo.io/${encodeURIComponent(ip.trim())}/json`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const endTime = performance.now();
|
||||
|
||||
setQueryTime(endTime - startTime);
|
||||
|
||||
// ipinfo.io 返回格式已经符合 IPInfo 接口
|
||||
setIpInfo(data);
|
||||
toast.success("Query successful");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Query failed: ${error.message}`);
|
||||
} else {
|
||||
toast.error("Query failed");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
queryIP();
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskLevel = () => {
|
||||
if (!ipInfo) return null;
|
||||
|
||||
// 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: "Medium",
|
||||
color: "text-yellow-500",
|
||||
reasons: ["Possible Hosting/Datacenter IP"],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level: "Low",
|
||||
color: "text-green-500",
|
||||
reasons: ["Regular residential IP"],
|
||||
};
|
||||
};
|
||||
|
||||
const riskInfo = getRiskLevel();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">IP Address</label>
|
||||
<Input
|
||||
placeholder="e.g. 8.8.8.8 or leave empty for current IP"
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Supports IPv4 and IPv6 addresses
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={queryIP} disabled={loading} className="flex-1">
|
||||
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{loading ? "Querying..." : "Query IP"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={queryCurrentIP}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
Query My IP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queryTime > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Query time: {queryTime.toFixed(2)} ms
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ipInfo && (
|
||||
<div className="flex flex-col gap-3 flex-1 overflow-auto">
|
||||
<div className="text-sm font-medium">IP Information:</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="border rounded-md p-4 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Basic Information</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">IP Address:</div>
|
||||
<div className="font-mono">{ipInfo.ip || ipInfo.query}</div>
|
||||
|
||||
{ipInfo.country && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Country:</div>
|
||||
<div>{ipInfo.country} ({ipInfo.countryCode})</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.region && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Region:</div>
|
||||
<div>{ipInfo.region}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.city && (
|
||||
<>
|
||||
<div className="text-muted-foreground">City:</div>
|
||||
<div>{ipInfo.city}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.loc && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Coordinates:</div>
|
||||
<div className="font-mono">{ipInfo.loc}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.timezone && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Timezone:</div>
|
||||
<div>{ipInfo.timezone}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网络信息 */}
|
||||
{(ipInfo.isp || ipInfo.org || ipInfo.as) && (
|
||||
<div className="border rounded-md p-4 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Network Information</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{ipInfo.isp && (
|
||||
<>
|
||||
<div className="text-muted-foreground">ISP:</div>
|
||||
<div>{ipInfo.isp}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.org && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Organization:</div>
|
||||
<div>{ipInfo.org}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipInfo.as && (
|
||||
<>
|
||||
<div className="text-muted-foreground">AS Number:</div>
|
||||
<div className="font-mono">{ipInfo.as}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 风险评估 */}
|
||||
{riskInfo && (
|
||||
<div className="border rounded-md p-4 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Risk Assessment</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Risk Level:</div>
|
||||
<div className={`font-medium ${riskInfo.color}`}>
|
||||
{riskInfo.level}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">Details:</div>
|
||||
<div className="space-y-1">
|
||||
{riskInfo.reasons.map((reason, idx) => (
|
||||
<div key={idx} className="text-sm">{reason}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tool;
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
18
src/main.tsx
18
src/main.tsx
@@ -13,3 +13,21 @@ 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('installed', (event) => {
|
||||
if (event.isUpdate) {
|
||||
console.log('New service worker installed, reloading page...')
|
||||
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" />
|
||||
|
||||
@@ -2,13 +2,81 @@ 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 * 5 // 5 分钟
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: true
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
// React核心库
|
||||
if (id.includes('node_modules/react') ||
|
||||
id.includes('node_modules/react-dom') ||
|
||||
id.includes('node_modules/react-router-dom')) {
|
||||
return 'react-vendor';
|
||||
}
|
||||
// Radix UI组件
|
||||
if (id.includes('node_modules/@radix-ui')) {
|
||||
return 'ui-vendor';
|
||||
}
|
||||
// 图标库
|
||||
if (id.includes('node_modules/lucide-react')) {
|
||||
return 'icons';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user