Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ef1a1e70 | ||
|
|
297000f208 | ||
|
|
04fbf12e07 | ||
| a9a6354b2d | |||
|
|
109139a42e | ||
|
|
b5a811e5ee | ||
|
|
b3adfe5c8f | ||
|
|
8eda2eae99 | ||
|
|
99673913a6 | ||
|
|
83e48e3485 | ||
|
|
8607591871 | ||
|
|
24c154a759 | ||
|
|
edf87370d9 | ||
|
|
ae0f9447ea | ||
|
|
972b6c7f22 | ||
|
|
be56d896ca | ||
| 3b31ce9ddf |
@@ -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
|
||||
|
||||
50
index.html
50
index.html
@@ -2,9 +2,55 @@
|
||||
<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" />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
10
package.json
10
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "litek",
|
||||
"private": true,
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "npm run generate:sitemap && 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",
|
||||
@@ -13,6 +14,7 @@
|
||||
"release:major": "npm version major && git push --follow-tags"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-separator": "^1.1.7",
|
||||
@@ -42,6 +44,7 @@
|
||||
"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",
|
||||
@@ -52,7 +55,8 @@
|
||||
"vite": "npm:rolldown-vite@7.1.14"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core"
|
||||
"@swc/core",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
345
pnpm-lock.yaml
generated
345
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -28,7 +31,7 @@ importers:
|
||||
version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.16
|
||||
version: 4.1.16(rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1))
|
||||
version: 4.1.16(rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -80,7 +83,7 @@ importers:
|
||||
version: 19.2.2(@types/react@19.2.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1))
|
||||
version: 5.1.0(rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6))
|
||||
eslint:
|
||||
specifier: ^9.36.0
|
||||
version: 9.38.0(jiti@2.6.1)
|
||||
@@ -93,6 +96,9 @@ importers:
|
||||
globals:
|
||||
specifier: ^16.4.0
|
||||
version: 16.4.0
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.20.6
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
@@ -104,7 +110,7 @@ importers:
|
||||
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: npm:rolldown-vite@7.1.14
|
||||
version: rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1)
|
||||
version: rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -200,6 +206,162 @@ packages:
|
||||
'@emnapi/wasi-threads@1.1.0':
|
||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0':
|
||||
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -323,6 +485,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -1019,6 +1194,11 @@ packages:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
esbuild@0.25.11:
|
||||
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1137,6 +1317,9 @@ packages:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1473,6 +1656,9 @@ packages:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
@@ -1593,6 +1779,11 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.20.6:
|
||||
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
@@ -1794,6 +1985,84 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
@@ -1921,6 +2190,22 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
@@ -2319,12 +2604,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.16
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.16
|
||||
|
||||
'@tailwindcss/vite@4.1.16(rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1))':
|
||||
'@tailwindcss/vite@4.1.16(rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.16
|
||||
'@tailwindcss/oxide': 4.1.16
|
||||
tailwindcss: 4.1.16
|
||||
vite: rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1)
|
||||
vite: rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6)
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
@@ -2461,7 +2746,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@5.1.0(rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1))':
|
||||
'@vitejs/plugin-react@5.1.0(rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
@@ -2469,7 +2754,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.43
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1)
|
||||
vite: rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2575,6 +2860,35 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
esbuild@0.25.11:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.11
|
||||
'@esbuild/android-arm': 0.25.11
|
||||
'@esbuild/android-arm64': 0.25.11
|
||||
'@esbuild/android-x64': 0.25.11
|
||||
'@esbuild/darwin-arm64': 0.25.11
|
||||
'@esbuild/darwin-x64': 0.25.11
|
||||
'@esbuild/freebsd-arm64': 0.25.11
|
||||
'@esbuild/freebsd-x64': 0.25.11
|
||||
'@esbuild/linux-arm': 0.25.11
|
||||
'@esbuild/linux-arm64': 0.25.11
|
||||
'@esbuild/linux-ia32': 0.25.11
|
||||
'@esbuild/linux-loong64': 0.25.11
|
||||
'@esbuild/linux-mips64el': 0.25.11
|
||||
'@esbuild/linux-ppc64': 0.25.11
|
||||
'@esbuild/linux-riscv64': 0.25.11
|
||||
'@esbuild/linux-s390x': 0.25.11
|
||||
'@esbuild/linux-x64': 0.25.11
|
||||
'@esbuild/netbsd-arm64': 0.25.11
|
||||
'@esbuild/netbsd-x64': 0.25.11
|
||||
'@esbuild/openbsd-arm64': 0.25.11
|
||||
'@esbuild/openbsd-x64': 0.25.11
|
||||
'@esbuild/openharmony-arm64': 0.25.11
|
||||
'@esbuild/sunos-x64': 0.25.11
|
||||
'@esbuild/win32-arm64': 0.25.11
|
||||
'@esbuild/win32-ia32': 0.25.11
|
||||
'@esbuild/win32-x64': 0.25.11
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
@@ -2704,6 +3018,10 @@ snapshots:
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -2962,9 +3280,11 @@ snapshots:
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rolldown-vite@7.1.14(@types/node@24.9.1)(jiti@2.6.1):
|
||||
rolldown-vite@7.1.14(@types/node@24.9.1)(esbuild@0.25.11)(jiti@2.6.1)(tsx@4.20.6):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.92.0
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -2975,8 +3295,10 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.9.1
|
||||
esbuild: 0.25.11
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
tsx: 4.20.6
|
||||
|
||||
rolldown@1.0.0-beta.41:
|
||||
dependencies:
|
||||
@@ -3051,6 +3373,13 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.20.6:
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
get-tsconfig: 4.13.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
|
||||
@@ -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">
|
||||
<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_bgCarrier" stroke-width="0"/>
|
||||
<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,32 +1,89 @@
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
import { tools } from "@/components/tool";
|
||||
import type { ReactNode } from "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 = () => (
|
||||
export const AppSidebar = () => {
|
||||
// 递归构建完整路径
|
||||
const buildFullPath = (pathSegments: string[]): string => {
|
||||
return `/tool/${pathSegments.join("/")}`;
|
||||
};
|
||||
|
||||
// 递归渲染菜单项
|
||||
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
|
||||
const currentPaths = [...parentPaths, tool.path];
|
||||
|
||||
if (tool.children) {
|
||||
// 有子菜单的项目
|
||||
return (
|
||||
<Collapsible
|
||||
key={tool.name}
|
||||
defaultOpen={false}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={tool.description}>
|
||||
{tool.icon}
|
||||
<span>{tool.name}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<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>
|
||||
)}
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有子菜单的项目
|
||||
return (
|
||||
<SidebarMenuItem key={tool.name}>
|
||||
<SidebarMenuButton asChild tooltip={tool.description}>
|
||||
<Link to={buildFullPath(currentPaths)}>
|
||||
{tool.icon}
|
||||
<span>{tool.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="text-2xl font-bold flex justify-center items-center">
|
||||
Lite Kit
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Tools
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>Tools</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{
|
||||
tools.map((tool) => (
|
||||
<SidebarMenuItem key={tool.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={`/tool/${tool.path}`} title={tool.description}>
|
||||
{tool.icon}
|
||||
{tool.name}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
}
|
||||
<SidebarMenu>
|
||||
{tools.map((tool) => renderMenuItem(tool))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
@@ -37,4 +94,5 @@ export const AppSidebar = () => (
|
||||
<ModeToggle />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { FileJson, Hash, Binary } from 'lucide-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'
|
||||
|
||||
export interface Tool {
|
||||
path: string;
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
description: string;
|
||||
component: ReactNode;
|
||||
component?: ReactNode;
|
||||
children?: Tool[];
|
||||
}
|
||||
|
||||
export const tools: Tool[] = [
|
||||
@@ -34,5 +36,48 @@ export const tools: Tool[] = [
|
||||
description: "Encode and decode Base64",
|
||||
icon: <Binary />,
|
||||
component: <Base64 />,
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "network",
|
||||
name: "Network Tools",
|
||||
description: "Network testing tools",
|
||||
icon: <Network />,
|
||||
children: [
|
||||
{
|
||||
path: "dns",
|
||||
name: "DNS Lookup",
|
||||
description: "DNS query tool",
|
||||
icon: <Globe />,
|
||||
component: <DNS />,
|
||||
},
|
||||
{
|
||||
path: "ping",
|
||||
name: "Ping",
|
||||
description: "Ping test tool",
|
||||
icon: <Activity />,
|
||||
component: <Ping />,
|
||||
},
|
||||
{
|
||||
path: "tcping",
|
||||
name: "TCPing",
|
||||
description: "TCP port connectivity test",
|
||||
icon: <Wifi />,
|
||||
component: <TCPing />,
|
||||
},
|
||||
{
|
||||
path: "speedtest",
|
||||
name: "Speed Test",
|
||||
description: "Website speed test",
|
||||
icon: <Gauge />,
|
||||
component: <SpeedTest />,
|
||||
},
|
||||
{
|
||||
path: "ipquery",
|
||||
name: "IP Query",
|
||||
description: "Query IP location, quality and risk info",
|
||||
icon: <MapPin />,
|
||||
component: <IPQuery />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
200
src/components/tool/network/dns.tsx
Normal file
200
src/components/tool/network/dns.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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 DNSRecord {
|
||||
name: string;
|
||||
type: number;
|
||||
TTL: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface DNSResponse {
|
||||
Status: number;
|
||||
Answer?: DNSRecord[];
|
||||
Question?: Array<{ name: string; type: number }>;
|
||||
}
|
||||
|
||||
const DNS_RECORD_TYPES = [
|
||||
{ value: "1", label: "A", description: "IPv4 Address" },
|
||||
{ value: "28", label: "AAAA", description: "IPv6 Address" },
|
||||
{ value: "5", label: "CNAME", description: "Canonical Name" },
|
||||
{ value: "15", label: "MX", description: "Mail Exchange" },
|
||||
{ value: "2", label: "NS", description: "Name Server" },
|
||||
{ value: "16", label: "TXT", description: "Text Record" },
|
||||
{ value: "6", label: "SOA", description: "Start of Authority" },
|
||||
{ value: "257", label: "CAA", description: "Certification Authority Authorization" },
|
||||
{ value: "12", label: "PTR", description: "Pointer Record" },
|
||||
{ value: "33", label: "SRV", description: "Service Record" },
|
||||
];
|
||||
|
||||
const getRecordTypeName = (type: number): string => {
|
||||
const record = DNS_RECORD_TYPES.find((r) => r.value === String(type));
|
||||
return record ? record.label : `TYPE${type}`;
|
||||
};
|
||||
|
||||
const Tool: FC = () => {
|
||||
const [domain, setDomain] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [results, setResults] = useState<DNSRecord[]>([]);
|
||||
const [queryTime, setQueryTime] = useState<number>(0);
|
||||
|
||||
const handleDomainBlur = () => {
|
||||
if (!domain.trim()) return;
|
||||
|
||||
let input = domain.trim();
|
||||
let cleanDomain = input;
|
||||
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
|
||||
cleanDomain = url.hostname;
|
||||
} catch {
|
||||
// If parsing fails, fallback to manual cleanup
|
||||
cleanDomain = input.replace(/^https?:\/\//, "").split("/")[0].split(":")[0];
|
||||
}
|
||||
|
||||
if (cleanDomain !== input) {
|
||||
setDomain(cleanDomain);
|
||||
}
|
||||
};
|
||||
|
||||
const queryDNS = async () => {
|
||||
if (!domain.trim()) {
|
||||
toast.error("Please enter a domain name");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
setQueryTime(0);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Query all record types concurrently
|
||||
const queries = DNS_RECORD_TYPES.map((recordType) =>
|
||||
fetch(
|
||||
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(
|
||||
domain.trim()
|
||||
)}&type=${recordType.value}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/dns-json",
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data: DNSResponse) => {
|
||||
if (data.Status === 0 && data.Answer && data.Answer.length > 0) {
|
||||
return data.Answer;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.catch(() => [])
|
||||
);
|
||||
|
||||
const allResults = await Promise.all(queries);
|
||||
const endTime = performance.now();
|
||||
setQueryTime(endTime - startTime);
|
||||
|
||||
// Merge and deduplicate results
|
||||
const combinedResults = allResults.flat();
|
||||
|
||||
if (combinedResults.length > 0) {
|
||||
// Group by record type and deduplicate
|
||||
const uniqueResults = Array.from(
|
||||
new Map(
|
||||
combinedResults.map((record) => [
|
||||
`${record.name}-${record.type}-${record.data}`,
|
||||
record,
|
||||
])
|
||||
).values()
|
||||
);
|
||||
|
||||
setResults(uniqueResults);
|
||||
toast.success(`Query successful, found ${uniqueResults.length} record(s)`);
|
||||
} else {
|
||||
setResults([]);
|
||||
toast.info("No records found");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Query failed: ${error.message}`);
|
||||
} else {
|
||||
toast.error("Query failed");
|
||||
}
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
queryDNS();
|
||||
}
|
||||
};
|
||||
|
||||
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">Domain Name</label>
|
||||
<Input
|
||||
placeholder="e.g. example.com"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
onBlur={handleDomainBlur}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Will automatically query all DNS record types
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button onClick={queryDNS} disabled={loading} className="w-full">
|
||||
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{loading ? "Querying..." : "Query All Records"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{queryTime > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Query time: {queryTime.toFixed(2)} ms
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="flex flex-col gap-3 flex-1 overflow-auto">
|
||||
<div className="text-sm font-medium">Query Results:</div>
|
||||
<div className="space-y-2">
|
||||
{results.map((record, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border rounded-md p-3 bg-card text-card-foreground"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Name:</div>
|
||||
<div className="font-mono break-all">{record.name}</div>
|
||||
<div className="text-muted-foreground">Type:</div>
|
||||
<div>{getRecordTypeName(record.type)}</div>
|
||||
<div className="text-muted-foreground">TTL:</div>
|
||||
<div>{record.TTL} seconds</div>
|
||||
<div className="text-muted-foreground">Data:</div>
|
||||
<div className="font-mono break-all">{record.data}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tool;
|
||||
|
||||
6
src/components/tool/network/index.tsx
Normal file
6
src/components/tool/network/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
278
src/components/tool/network/ping.tsx
Normal file
278
src/components/tool/network/ping.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState, useEffect, useRef, 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 PingResult {
|
||||
seq: number;
|
||||
time: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PingStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
lost: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
const Tool: FC = () => {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [running, setRunning] = useState<boolean>(false);
|
||||
const [results, setResults] = useState<PingResult[]>([]);
|
||||
const [stats, setStats] = useState<PingStats>({
|
||||
sent: 0,
|
||||
received: 0,
|
||||
lost: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
avg: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const seqRef = useRef<number>(0);
|
||||
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleUrlBlur = () => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
let input = url.trim();
|
||||
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
|
||||
const normalizedUrl = parsedUrl.toString();
|
||||
|
||||
if (normalizedUrl !== input) {
|
||||
setUrl(normalizedUrl);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, add https:// prefix
|
||||
if (!input.startsWith("http://") && !input.startsWith("https://")) {
|
||||
setUrl(`https://${input}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ping = async () => {
|
||||
if (!url.trim()) {
|
||||
toast.error("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current;
|
||||
const targetUrl = url.trim();
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
await fetch(targetUrl, {
|
||||
method: "HEAD",
|
||||
mode: "no-cors",
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const time = endTime - startTime;
|
||||
|
||||
const newResult: PingResult = {
|
||||
seq,
|
||||
time,
|
||||
success: true,
|
||||
};
|
||||
|
||||
setResults((prev) => [...prev, newResult]);
|
||||
updateStats(newResult);
|
||||
} catch (error: unknown) {
|
||||
const endTime = performance.now();
|
||||
const time = endTime - startTime;
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Request failed";
|
||||
|
||||
const newResult: PingResult = {
|
||||
seq,
|
||||
time,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
setResults((prev) => [...prev, newResult]);
|
||||
updateStats(newResult);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStats = (newResult: PingResult) => {
|
||||
setStats((prev) => {
|
||||
const sent = prev.sent + 1;
|
||||
const received = newResult.success ? prev.received + 1 : prev.received;
|
||||
const lost = sent - received;
|
||||
|
||||
let min = prev.min;
|
||||
let max = prev.max;
|
||||
let avg = prev.avg;
|
||||
|
||||
if (newResult.success) {
|
||||
if (received === 1) {
|
||||
min = newResult.time;
|
||||
max = newResult.time;
|
||||
avg = newResult.time;
|
||||
} else {
|
||||
min = Math.min(min, newResult.time);
|
||||
max = Math.max(max, newResult.time);
|
||||
avg = (prev.avg * (received - 1) + newResult.time) / received;
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, received, lost, min, max, avg };
|
||||
});
|
||||
};
|
||||
|
||||
const startPing = () => {
|
||||
if (!url.trim()) {
|
||||
toast.error("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setStats({
|
||||
sent: 0,
|
||||
received: 0,
|
||||
lost: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
avg: 0,
|
||||
});
|
||||
seqRef.current = 0;
|
||||
|
||||
// Execute first ping immediately
|
||||
ping();
|
||||
|
||||
// Then execute every second
|
||||
intervalRef.current = window.setInterval(ping, 1000);
|
||||
};
|
||||
|
||||
const stopPing = () => {
|
||||
setRunning(false);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-scroll to bottom
|
||||
if (resultsContainerRef.current) {
|
||||
resultsContainerRef.current.scrollTop =
|
||||
resultsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup timer
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lossRate =
|
||||
stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0";
|
||||
|
||||
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">Target URL or IP</label>
|
||||
<Input
|
||||
placeholder="e.g. example.com or https://example.com"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onBlur={handleUrlBlur}
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!running ? (
|
||||
<Button onClick={startPing} className="flex-1">
|
||||
Start Ping
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={stopPing} variant="destructive" className="flex-1">
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.sent > 0 && (
|
||||
<div className="border rounded-md p-3 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-2">Statistics</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Sent:</div>
|
||||
<div>{stats.sent} packets</div>
|
||||
<div className="text-muted-foreground">Received:</div>
|
||||
<div>{stats.received} packets</div>
|
||||
<div className="text-muted-foreground">Lost:</div>
|
||||
<div>
|
||||
{stats.lost} packets ({lossRate}%)
|
||||
</div>
|
||||
{stats.received > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Min Latency:</div>
|
||||
<div>{stats.min.toFixed(2)} ms</div>
|
||||
<div className="text-muted-foreground">Max Latency:</div>
|
||||
<div>{stats.max.toFixed(2)} ms</div>
|
||||
<div className="text-muted-foreground">Avg Latency:</div>
|
||||
<div>{stats.avg.toFixed(2)} ms</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
<div className="text-sm font-medium">Ping Results:</div>
|
||||
<div
|
||||
ref={resultsContainerRef}
|
||||
className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card"
|
||||
>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.seq}
|
||||
className={result.success ? "text-green-500" : "text-red-500"}
|
||||
>
|
||||
{result.success ? (
|
||||
<>
|
||||
seq={result.seq} time={result.time.toFixed(2)}ms
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
seq={result.seq} Request timeout
|
||||
{result.error && ` (${result.error})`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{running && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Running...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tool;
|
||||
|
||||
355
src/components/tool/network/speedtest.tsx
Normal file
355
src/components/tool/network/speedtest.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
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 PerformanceMetrics {
|
||||
dns: number;
|
||||
tcp: number;
|
||||
ssl: number;
|
||||
ttfb: number;
|
||||
download: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface SpeedTestResult {
|
||||
downloadSpeed?: number;
|
||||
uploadSpeed?: number;
|
||||
performance?: PerformanceMetrics;
|
||||
}
|
||||
|
||||
const Tool: FC = () => {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [testType, setTestType] = useState<"performance" | "download" | "upload">(
|
||||
"performance"
|
||||
);
|
||||
const [testing, setTesting] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<SpeedTestResult | null>(null);
|
||||
|
||||
const testPerformance = async (targetUrl: string) => {
|
||||
// 清除之前的性能数据
|
||||
performance.clearResourceTimings();
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 等待内容加载完成
|
||||
await response.blob();
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
// 获取性能数据
|
||||
const perfEntries = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
const entry = perfEntries.find((e) => e.name === targetUrl);
|
||||
|
||||
if (entry) {
|
||||
const metrics: PerformanceMetrics = {
|
||||
dns: entry.domainLookupEnd - entry.domainLookupStart,
|
||||
tcp: entry.connectEnd - entry.connectStart,
|
||||
ssl:
|
||||
entry.secureConnectionStart > 0
|
||||
? entry.connectEnd - entry.secureConnectionStart
|
||||
: 0,
|
||||
ttfb: entry.responseStart - entry.requestStart,
|
||||
download: entry.responseEnd - entry.responseStart,
|
||||
total: entry.responseEnd - entry.startTime,
|
||||
};
|
||||
|
||||
return { performance: metrics };
|
||||
} else {
|
||||
// If no detailed performance data, only return total time
|
||||
return {
|
||||
performance: {
|
||||
dns: 0,
|
||||
tcp: 0,
|
||||
ssl: 0,
|
||||
ttfb: 0,
|
||||
download: 0,
|
||||
total: endTime - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const testDownloadSpeed = async (targetUrl: string) => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const endTime = performance.now();
|
||||
|
||||
const fileSizeBytes = blob.size;
|
||||
const durationSeconds = (endTime - startTime) / 1000;
|
||||
const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000);
|
||||
|
||||
return { downloadSpeed: speedMbps };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const testUploadSpeed = async (targetUrl: string) => {
|
||||
// 生成 1MB 的测试数据
|
||||
const testData = new Uint8Array(1024 * 1024);
|
||||
for (let i = 0; i < testData.length; i++) {
|
||||
testData[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
body: testData,
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const fileSizeBytes = testData.length;
|
||||
const durationSeconds = (endTime - startTime) / 1000;
|
||||
const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000);
|
||||
|
||||
return { uploadSpeed: speedMbps };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlBlur = () => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
let input = url.trim();
|
||||
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
|
||||
const normalizedUrl = parsedUrl.toString();
|
||||
|
||||
if (normalizedUrl !== input) {
|
||||
setUrl(normalizedUrl);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, add https:// prefix
|
||||
if (!input.startsWith("http://") && !input.startsWith("https://")) {
|
||||
setUrl(`https://${input}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startTest = async () => {
|
||||
if (!url.trim()) {
|
||||
toast.error("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = url.trim();
|
||||
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
let testResult: SpeedTestResult = {};
|
||||
|
||||
switch (testType) {
|
||||
case "performance":
|
||||
testResult = await testPerformance(targetUrl);
|
||||
toast.success("Performance test completed");
|
||||
break;
|
||||
case "download":
|
||||
testResult = await testDownloadSpeed(targetUrl);
|
||||
toast.success("Download speed test completed");
|
||||
break;
|
||||
case "upload":
|
||||
testResult = await testUploadSpeed(targetUrl);
|
||||
toast.success("Upload speed test completed");
|
||||
break;
|
||||
}
|
||||
|
||||
setResult(testResult);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Test failed: ${error.message}`);
|
||||
} else {
|
||||
toast.error("Test failed");
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !testing) {
|
||||
startTest();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="border rounded-md p-3 bg-yellow-500/10 border-yellow-500/50">
|
||||
<div className="text-sm font-medium text-yellow-600 dark:text-yellow-400 mb-1">
|
||||
⚠️ CORS Restrictions
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Due to browser CORS security policies, some websites cannot be tested directly.</p>
|
||||
<p>Recommended websites to test:</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
<li>Public APIs with CORS support</li>
|
||||
<li>Your own websites (with configured CORS headers)</li>
|
||||
<li>Using CORS proxy services</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Target URL</label>
|
||||
<Input
|
||||
placeholder="e.g. https://example.com"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onBlur={handleUrlBlur}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={testing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Test Type</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={testType}
|
||||
onChange={(e) =>
|
||||
setTestType(e.target.value as "performance" | "download" | "upload")
|
||||
}
|
||||
disabled={testing}
|
||||
>
|
||||
<option value="performance">Page Load Performance</option>
|
||||
<option value="download">Download Speed</option>
|
||||
<option value="upload">Upload Speed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={startTest} disabled={testing} className="w-full">
|
||||
{testing && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{testing ? "Testing..." : "Start Test"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="flex flex-col gap-3 flex-1 overflow-auto">
|
||||
<div className="text-sm font-medium">Test Results:</div>
|
||||
|
||||
{result.performance && (
|
||||
<div className="border rounded-md p-3 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Page Load Performance</div>
|
||||
<div className="space-y-2">
|
||||
{result.performance.dns > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">DNS Lookup:</div>
|
||||
<div>{result.performance.dns.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
{result.performance.tcp > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">TCP Connection:</div>
|
||||
<div>{result.performance.tcp.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
{result.performance.ssl > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">SSL Handshake:</div>
|
||||
<div>{result.performance.ssl.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
{result.performance.ttfb > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Time to First Byte (TTFB):</div>
|
||||
<div>{result.performance.ttfb.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
{result.performance.download > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Content Download:</div>
|
||||
<div>{result.performance.download.toFixed(2)} ms</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm border-t pt-2 mt-2">
|
||||
<div className="text-muted-foreground font-medium">Total Time:</div>
|
||||
<div className="font-medium">
|
||||
{result.performance.total.toFixed(2)} ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.downloadSpeed !== undefined && (
|
||||
<div className="border rounded-md p-3 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Download Speed</div>
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{result.downloadSpeed.toFixed(2)} Mbps
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{(result.downloadSpeed / 8).toFixed(2)} MB/s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.uploadSpeed !== undefined && (
|
||||
<div className="border rounded-md p-3 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-3">Upload Speed</div>
|
||||
<div className="text-2xl font-bold text-blue-500">
|
||||
{result.uploadSpeed.toFixed(2)} Mbps
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{(result.uploadSpeed / 8).toFixed(2)} MB/s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testType === "download" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Note: Download speed test will download content from the target URL and calculate speed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testType === "upload" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Note: Upload speed test will send 1MB of test data to the target URL
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tool;
|
||||
|
||||
323
src/components/tool/network/tcping.tsx
Normal file
323
src/components/tool/network/tcping.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useRef, 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 TCPingResult {
|
||||
seq: number;
|
||||
time: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TCPingStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
lost: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
const Tool: FC = () => {
|
||||
const [host, setHost] = useState<string>("");
|
||||
const [port, setPort] = useState<string>("443");
|
||||
const [running, setRunning] = useState<boolean>(false);
|
||||
const [results, setResults] = useState<TCPingResult[]>([]);
|
||||
const [stats, setStats] = useState<TCPingStats>({
|
||||
sent: 0,
|
||||
received: 0,
|
||||
lost: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
avg: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const seqRef = useRef<number>(0);
|
||||
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleHostBlur = () => {
|
||||
if (!host.trim()) return;
|
||||
|
||||
let input = host.trim();
|
||||
let cleanHost = input;
|
||||
let extractedPort: string | null = null;
|
||||
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
|
||||
cleanHost = url.hostname;
|
||||
|
||||
// Extract port if specified in URL
|
||||
if (url.port) {
|
||||
extractedPort = url.port;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fallback to manual cleanup
|
||||
const withoutProtocol = input.replace(/^https?:\/\//, "");
|
||||
const withoutPath = withoutProtocol.split("/")[0];
|
||||
|
||||
// Check for port in the format hostname:port
|
||||
const portMatch = withoutPath.match(/^(.+):(\d+)$/);
|
||||
if (portMatch) {
|
||||
cleanHost = portMatch[1];
|
||||
extractedPort = portMatch[2];
|
||||
} else {
|
||||
cleanHost = withoutPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanHost !== input) {
|
||||
setHost(cleanHost);
|
||||
}
|
||||
|
||||
if (extractedPort) {
|
||||
setPort(extractedPort);
|
||||
}
|
||||
};
|
||||
|
||||
const tcping = async () => {
|
||||
if (!host.trim()) {
|
||||
toast.error("Please enter a hostname or IP");
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current;
|
||||
const portNum = parseInt(port) || 443;
|
||||
const targetHost = host.trim();
|
||||
|
||||
// Build test URL
|
||||
const protocol = portNum === 443 ? "https" : "http";
|
||||
const url = `${protocol}://${targetHost}:${portNum}`;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 使用 fetch 测试连接
|
||||
await fetch(url, {
|
||||
method: "HEAD",
|
||||
mode: "no-cors",
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const time = endTime - startTime;
|
||||
|
||||
const newResult: TCPingResult = {
|
||||
seq,
|
||||
time,
|
||||
success: true,
|
||||
};
|
||||
|
||||
setResults((prev) => [...prev, newResult]);
|
||||
updateStats(newResult);
|
||||
} catch (error: unknown) {
|
||||
const endTime = performance.now();
|
||||
const time = endTime - startTime;
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Connection failed";
|
||||
|
||||
const newResult: TCPingResult = {
|
||||
seq,
|
||||
time,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
setResults((prev) => [...prev, newResult]);
|
||||
updateStats(newResult);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStats = (newResult: TCPingResult) => {
|
||||
setStats((prev) => {
|
||||
const sent = prev.sent + 1;
|
||||
const received = newResult.success ? prev.received + 1 : prev.received;
|
||||
const lost = sent - received;
|
||||
|
||||
let min = prev.min;
|
||||
let max = prev.max;
|
||||
let avg = prev.avg;
|
||||
|
||||
if (newResult.success) {
|
||||
if (received === 1) {
|
||||
min = newResult.time;
|
||||
max = newResult.time;
|
||||
avg = newResult.time;
|
||||
} else {
|
||||
min = Math.min(min, newResult.time);
|
||||
max = Math.max(max, newResult.time);
|
||||
avg = (prev.avg * (received - 1) + newResult.time) / received;
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, received, lost, min, max, avg };
|
||||
});
|
||||
};
|
||||
|
||||
const startTCPing = () => {
|
||||
if (!host.trim()) {
|
||||
toast.error("Please enter a hostname or IP");
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setStats({
|
||||
sent: 0,
|
||||
received: 0,
|
||||
lost: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
avg: 0,
|
||||
});
|
||||
seqRef.current = 0;
|
||||
|
||||
// 立即执行第一次 tcping
|
||||
tcping();
|
||||
|
||||
// 然后每秒执行一次
|
||||
intervalRef.current = window.setInterval(tcping, 1000);
|
||||
};
|
||||
|
||||
const stopTCPing = () => {
|
||||
setRunning(false);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 自动滚动到底部
|
||||
if (resultsContainerRef.current) {
|
||||
resultsContainerRef.current.scrollTop =
|
||||
resultsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lossRate =
|
||||
stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0";
|
||||
|
||||
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">Hostname or IP</label>
|
||||
<Input
|
||||
placeholder="e.g. example.com or 192.168.1.1"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onBlur={handleHostBlur}
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 443"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
disabled={running}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!running ? (
|
||||
<Button onClick={startTCPing} className="flex-1">
|
||||
Start Test
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={stopTCPing}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.sent > 0 && (
|
||||
<div className="border rounded-md p-3 bg-card text-card-foreground">
|
||||
<div className="text-sm font-medium mb-2">Statistics</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-muted-foreground">Sent:</div>
|
||||
<div>{stats.sent} times</div>
|
||||
<div className="text-muted-foreground">Success:</div>
|
||||
<div>{stats.received} times</div>
|
||||
<div className="text-muted-foreground">Failed:</div>
|
||||
<div>
|
||||
{stats.lost} times ({lossRate}%)
|
||||
</div>
|
||||
{stats.received > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Min Latency:</div>
|
||||
<div>{stats.min.toFixed(2)} ms</div>
|
||||
<div className="text-muted-foreground">Max Latency:</div>
|
||||
<div>{stats.max.toFixed(2)} ms</div>
|
||||
<div className="text-muted-foreground">Avg Latency:</div>
|
||||
<div>{stats.avg.toFixed(2)} ms</div>
|
||||
<div className="text-muted-foreground">Port Status:</div>
|
||||
<div className="text-green-500 font-medium">Open</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
<div className="text-sm font-medium">TCPing Results:</div>
|
||||
<div
|
||||
ref={resultsContainerRef}
|
||||
className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card"
|
||||
>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.seq}
|
||||
className={result.success ? "text-green-500" : "text-red-500"}
|
||||
>
|
||||
{result.success ? (
|
||||
<>
|
||||
seq={result.seq} port={port} time={result.time.toFixed(2)}ms
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
seq={result.seq} port={port} Connection failed
|
||||
{result.error && ` (${result.error})`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{running && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Running...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tool;
|
||||
|
||||
12
src/components/ui/collapsible.tsx
Normal file
12
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
@@ -118,3 +118,50 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.551 0.027 264.364 / 0.3) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.551 0.027 264.364 / 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.551 0.027 264.364 / 0.5);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.551 0.027 264.364 / 0.6);
|
||||
}
|
||||
|
||||
/* 深色模式下的滚动条 */
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.707 0.022 261.325 / 0.4);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.707 0.022 261.325 / 0.6);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.707 0.022 261.325 / 0.7);
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(0.707 0.022 261.325 / 0.4) transparent;
|
||||
}
|
||||
@@ -4,8 +4,13 @@ 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 { useSEO } from "@/hooks/use-seo";
|
||||
|
||||
export const Layout: FC = () => (
|
||||
export const Layout: FC = () => {
|
||||
// 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据
|
||||
useSEO();
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
@@ -21,3 +26,4 @@ export const Layout: FC = () => (
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,30 @@ import {
|
||||
createBrowserRouter,
|
||||
redirect,
|
||||
RouterProvider,
|
||||
type RouteObject,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { tools } from "@/components/tool";
|
||||
import { tools, type Tool } from "@/components/tool";
|
||||
import { Layout } from "./layout";
|
||||
|
||||
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
|
||||
return tools.map((tool) => {
|
||||
const route: RouteObject = {
|
||||
path: tool.path,
|
||||
};
|
||||
|
||||
if (tool.component) {
|
||||
route.element = tool.component;
|
||||
}
|
||||
|
||||
if (tool.children && tool.children.length > 0) {
|
||||
route.children = buildToolRoutes(tool.children);
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
};
|
||||
|
||||
// 路由配置
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -16,19 +35,14 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: "tool",
|
||||
children: [
|
||||
...tools.map((tool) => (
|
||||
{
|
||||
path: tool.path,
|
||||
element: tool.component,
|
||||
}
|
||||
)),
|
||||
...buildToolRoutes(tools),
|
||||
{
|
||||
index: true,
|
||||
loader: () => redirect("/tool/uuid"),
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
index: true,
|
||||
|
||||
Reference in New Issue
Block a user