35 Commits

Author SHA1 Message Date
typist
b4ba7a2219 0.0.22
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m52s
2025-10-29 09:11:11 +08:00
typist
25e42e3af5 fix: update caching strategy for ipinfo API in Vite config
- Changed caching handler from 'NetworkFirst' to 'CacheFirst' for improved performance.
- Extended cache expiration time from 5 minutes to 1 hour to enhance data availability.
2025-10-29 09:10:59 +08:00
typist
4398b53ea7 0.0.21
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-29 09:07:27 +08:00
typist
3e14bc652f feat: add terser for advanced minification and improve React chunking
- Added terser as a dependency for enhanced code minification.
- Updated Vite configuration to enable aggressive minification using terser with options to drop console logs and unused code.
- Refined manual chunking for React libraries to improve code splitting and loading efficiency.
2025-10-29 09:06:56 +08:00
typist
aca7e11835 0.0.20
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m10s
2025-10-29 08:58:11 +08:00
typist
782de6e38a feat: integrate PWA support and service worker registration
- Added vite-plugin-pwa for Progressive Web App capabilities.
- Configured service worker with caching strategies for improved offline support.
- Registered service worker in main.tsx to handle updates and caching.
- Updated package.json to include new dependencies for PWA functionality.
2025-10-29 08:58:02 +08:00
typist
7646830194 0.0.19
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:50:18 +08:00
typist
59f998e8e3 feat: enhance tool loading and performance optimization
- Added DNS prefetch and preconnect links in index.html for improved API loading times.
- Implemented lazy loading for tool components to optimize performance and reduce initial load time.
- Wrapped tool components in Suspense with a loading fallback to enhance user experience during loading.
- Refactored Vite configuration to create manual chunks for better code splitting of React and UI libraries.
2025-10-29 08:49:25 +08:00
typist
55207beff5 0.0.18
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m39s
2025-10-29 08:33:42 +08:00
typist
e98a344b95 feat: enhance sidebar component with recursive submenu rendering
- Implemented recursive rendering of submenu items in the sidebar for better navigation.
- Added support for collapsible submenus, improving user experience with nested tools.
- Refactored menu item rendering logic to utilize a new renderSubMenuContent function for cleaner code.
2025-10-29 08:33:22 +08:00
typist
d553c3e04c 0.0.17
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 08:27:15 +08:00
typist
e2da2758cc chore: simplify build script in package.json
- Removed sitemap generation step from the build script for a more streamlined build process.
2025-10-29 08:27:00 +08:00
typist
3ab70498e6 feat: add ModeToggle component to layout and sidebar
- Integrated ModeToggle component into the layout for theme switching.
- Replaced the existing button in the sidebar footer with SidebarMenuButton for improved styling and functionality.
2025-10-29 08:26:26 +08:00
typist
a5ef1a1e70 0.0.16
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
2025-10-29 08:16:17 +08:00
typist
297000f208 feat: integrate SEO hook for dynamic metadata management
- Removed hardcoded canonical link from index.html.
- Implemented useSEO hook in layout.tsx to dynamically update SEO metadata including title, description, and canonical URL based on the current route.
- Added new use-seo.ts file containing the SEO hook logic for improved search engine optimization.
2025-10-29 08:15:53 +08:00
typist
04fbf12e07 0.0.15
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:06:21 +08:00
a9a6354b2d Merge pull request 'feat: implement SEO enhancements and sitemap generation' (#10) from feat/seo into main
Reviewed-on: #10
2025-10-29 08:05:18 +08:00
typist
109139a42e feat: implement SEO enhancements and sitemap generation
- Added comprehensive SEO meta tags in index.html for improved search engine visibility, including Open Graph and Twitter Card tags.
- Created a sitemap.xml for better indexing of site tools, generated automatically via a new script (generate-sitemap.ts).
- Introduced robots.txt to manage crawler access and specified sitemap location.
- Updated package.json to include a build step for sitemap generation and added tsx as a dependency.
- Documented SEO optimizations and usage instructions in a new SEO-README.md file.
2025-10-29 08:05:37 +08:00
typist
b5a811e5ee 0.0.14
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m0s
2025-10-29 07:28:46 +08:00
typist
b3adfe5c8f refactor: update IP query API and response handling
- Switched from ip-api.com to ipinfo.io for IP information retrieval, ensuring more reliable service.
- Simplified response handling by directly using the data returned from ipinfo.io, eliminating unnecessary data transformation.
- Adjusted risk level determination logic to utilize the 'org' field for identifying potential hosting or datacenter IPs.
2025-10-29 07:28:21 +08:00
typist
8eda2eae99 0.0.13
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 07:18:09 +08:00
typist
99673913a6 chore: comment out cache settings in build workflow
- Temporarily disabled cache-from and cache-to settings in the build workflow to prevent potential issues with the build process.
- Retained platform specification for compatibility.
2025-10-29 07:18:02 +08:00
typist
83e48e3485 0.0.12
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m40s
2025-10-29 07:10:25 +08:00
typist
8607591871 feat: enhance SVG for dark mode support
- Added CSS styles to the SVG for dark mode compatibility, allowing the icon to switch colors based on the user's color scheme preference.
- Cleaned up the SVG structure for better readability and maintainability.
2025-10-29 07:10:15 +08:00
typist
24c154a759 0.0.11
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m15s
2025-10-29 06:36:32 +08:00
typist
edf87370d9 feat: add IP Query tool
- Introduced a new tool for querying IP information, including location and risk assessment.
- Updated the tool index to include the new IP Query component and its corresponding icon.
- Added IPQuery component with functionality to validate and fetch IP data from external APIs.
2025-10-29 06:36:20 +08:00
typist
ae0f9447ea 0.0.10
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
2025-10-29 06:09:10 +08:00
typist
972b6c7f22 feat: enhance input handling for network tools
- Added domain and URL normalization on blur for DNS, Ping, Speedtest, and TCPing components.
- Improved user experience by ensuring valid input formats and extracting ports where applicable.
2025-10-29 06:08:47 +08:00
typist
be56d896ca 0.0.9
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-29 05:52:31 +08:00
3b31ce9ddf feat: add-tool network (#9)
Co-authored-by: typist <git@mail.typist.cc>
Reviewed-on: #9
2025-10-29 05:51:11 +08:00
typist
b97c746d36 0.0.8
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m49s
2025-10-28 22:29:24 +08:00
9793d96def feat: support dark-theme (#8)
Co-authored-by: typist <git@mail.typist.cc>
Reviewed-on: #8
2025-10-28 22:28:19 +08:00
typist
a6d9c23179 feat: add deployment workflow and trigger in build.yaml
- Introduced a new deploy.yaml workflow for production deployment.
- Updated build.yaml to trigger the deployment workflow after building the Docker image.
2025-10-28 22:06:13 +08:00
typist
28a86dcbff 0.0.7
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m24s
2025-10-28 04:40:54 +08:00
7cd826b052 feat: add-tool base64 (#7)
Co-authored-by: typist <git@mail.typist.cc>
Reviewed-on: #7
2025-10-28 04:39:39 +08:00
30 changed files with 6213 additions and 85 deletions

View File

@@ -36,14 +36,22 @@ jobs:
type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }} type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }}
- name: 构建并推送 Docker 镜像 - name: 构建并推送 Docker 镜像
id: build
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache # 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-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max
# platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 platforms: linux/amd64
- name: 触发部署 workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: deploy.yaml
token: ${{ secrets.GITHUB_TOKEN }}
inputs: '{"image_tag": "${{ github.ref_name }}"}'

View File

@@ -0,0 +1,82 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
image_tag:
description: '要部署的镜像标签 (例如: v1.0.0, latest)'
required: true
default: 'latest'
compose_path:
description: 'docker-compose 文件路径'
required: true
default: '/root/.compose/litek/compose.yaml'
workflow_call:
inputs:
image_tag:
description: '要部署的镜像标签'
required: true
type: string
compose_path:
description: 'docker-compose 文件路径'
required: false
type: string
default: '/root/.compose/litek/compose.yaml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 解析 SSH 连接信息
id: ssh-info
run: |
SSH_CONN="${{ secrets.SSH_CONNECTION }}"
# 格式: user@host:port
SSH_USER=$(echo $SSH_CONN | cut -d'@' -f1)
SSH_HOST_PORT=$(echo $SSH_CONN | cut -d'@' -f2)
SSH_HOST=$(echo $SSH_HOST_PORT | cut -d':' -f1)
SSH_PORT=$(echo $SSH_HOST_PORT | cut -d':' -f2)
echo "user=$SSH_USER" >> $GITHUB_OUTPUT
echo "host=$SSH_HOST" >> $GITHUB_OUTPUT
echo "port=$SSH_PORT" >> $GITHUB_OUTPUT
echo "SSH 连接信息已解析: $SSH_USER@$SSH_HOST:$SSH_PORT"
- name: 部署到生产服务器
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ steps.ssh-info.outputs.host }}
username: ${{ steps.ssh-info.outputs.user }}
port: ${{ steps.ssh-info.outputs.port }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
echo "开始部署 litek 应用..."
echo "镜像标签: ${{ inputs.image_tag }}"
echo "Compose 文件: ${{ inputs.compose_path }}"
# 切换到 compose 文件所在目录
COMPOSE_DIR=$(dirname "${{ inputs.compose_path }}")
cd "$COMPOSE_DIR"
echo "当前目录: $(pwd)"
# 拉取最新镜像
echo "正在拉取镜像..."
docker compose -f "${{ inputs.compose_path }}" pull
# 更新服务
echo "正在更新服务..."
docker compose -f "${{ inputs.compose_path }}" up -d
# 清理旧镜像
echo "清理未使用的镜像..."
docker image prune -f
echo "部署完成!"
# 显示运行状态
echo "当前运行的容器:"
docker compose -f "${{ inputs.compose_path }}" ps

171
SEO-README.md Normal file
View 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

View File

@@ -2,9 +2,59 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lite Kit</title>
<!-- Primary Meta Tags -->
<title>Lite Kit - Lightweight Online Tools</title>
<meta name="description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more. Fast, secure, and easy to use." />
<meta name="keywords" content="online tools,UUID generator,JSON formatter,Base64 encoder,network tools,DNS lookup,Ping test,TCPing,speed test,IP query" />
<meta name="author" content="Lite Kit" />
<meta name="theme-color" content="#000000" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/lite.svg" />
<!-- DNS Prefetch & Preconnect for external APIs -->
<link rel="dns-prefetch" href="https://ipinfo.io">
<link rel="preconnect" href="https://ipinfo.io" crossorigin>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://litek.typist.cc/" />
<meta property="og:title" content="Lite Kit - Lightweight Online Tools" />
<meta property="og:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." />
<meta property="og:image" content="https://litek.typist.cc/lite.svg" />
<meta property="og:site_name" content="Lite Kit" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Lite Kit - Lightweight Online Tools" />
<meta name="twitter:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." />
<meta name="twitter:image" content="https://litek.typist.cc/lite.svg" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Lite Kit",
"description": "Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more",
"url": "https://litek.typist.cc/",
"author": {
"@type": "Organization",
"name": "Lite Kit"
},
"applicationCategory": "UtilitiesApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,11 +1,12 @@
{ {
"name": "litek", "name": "litek",
"private": true, "private": true,
"version": "0.0.6", "version": "0.0.22",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"generate:sitemap": "tsx scripts/generate-sitemap.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"release:patch": "npm version patch && git push --follow-tags", "release:patch": "npm version patch && git push --follow-tags",
@@ -13,7 +14,9 @@
"release:major": "npm version major && git push --follow-tags" "release:major": "npm version major && git push --follow-tags"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
@@ -41,17 +44,22 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"terser": "^5.44.0",
"tsx": "^4.19.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.1.14",
"vite-plugin-pwa": "^1.1.0",
"workbox-window": "^7.3.0"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.1.14"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@swc/core" "@swc/core",
"esbuild"
] ]
} }
} }

3280
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,19 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style>
/* 默认浅色模式使用黑色 */
<g id="SVGRepo_bgCarrier" stroke-width="0"/> path {
fill: #000000;
}
/* 暗色模式使用白色 */
@media (prefers-color-scheme: dark) {
path {
fill: #ffffff;
}
}
</style>
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M12.6089 6.12601C12.6785 5.85878 12.5183 5.58573 12.251 5.51614C11.9838 5.44655 11.7108 5.60676 11.6412 5.87399L10.0842 11.8528L7.85646 12.5211C7.59196 12.6005 7.44187 12.8792 7.52122 13.1437C7.60057 13.4082 7.87931 13.5583 8.14381 13.479L9.78926 12.9853L8.51617 17.874C8.47715 18.0238 8.50974 18.1833 8.60443 18.3058C8.69911 18.4283 8.8452 18.5 9.00003 18.5H16C16.2762 18.5 16.5 18.2761 16.5 18C16.5 17.7239 16.2762 17.5 16 17.5H9.64691L10.9102 12.6491L13.1438 11.979C13.4083 11.8996 13.5584 11.6209 13.479 11.3564C13.3997 11.0919 13.121 10.9418 12.8565 11.0211L11.2051 11.5165L12.6089 6.12601Z"/> </g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

21
public/manifest.json Normal file
View 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
View 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
View 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>

View 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}`);

View File

@@ -1,35 +1,124 @@
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; import type { ReactNode } from "react";
import { tools } from "@/components/tool";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
export const AppSidebar = () => ( import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar";
<Sidebar> import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
<SidebarHeader className="text-2xl font-bold flex justify-center items-center"> import { tools, type Tool } from "@/components/tool";
Lite Kit
</SidebarHeader>
<SidebarContent> export const AppSidebar = () => {
<SidebarGroup> // 递归构建完整路径
<SidebarGroupLabel> const buildFullPath = (pathSegments: string[]): string => {
Tools return `/tool/${pathSegments.join("/")}`;
</SidebarGroupLabel> };
<SidebarGroupContent>
{ // 递归渲染子菜单内容
tools.map((tool) => ( const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => {
<SidebarMenuItem key={tool.name}> if (child.children) {
<SidebarMenuButton asChild> // 子菜单内的可折叠项
<Link to={`/tool/${tool.path}`} title={tool.description}> return (
{tool.icon} <Collapsible defaultOpen={false} className="group/collapsible">
{tool.name} <CollapsibleTrigger asChild>
</Link> <SidebarMenuSubButton>
</SidebarMenuButton> {child.icon}
</SidebarMenuItem> <span>{child.name}</span>
)) <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
} </SidebarMenuSubButton>
</SidebarGroupContent> </CollapsibleTrigger>
</SidebarGroup> <CollapsibleContent>
</SidebarContent> <SidebarMenuSub>
<SidebarFooter> {child.children.map((subChild) => (
<a href="mailto:litek@mail.typist.cc">contact us</a> <SidebarMenuSubItem key={subChild.name}>
</SidebarFooter> {renderSubMenuContent(subChild, [...currentPaths, child.path])}
</Sidebar> </SidebarMenuSubItem>
) ))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
);
}
// 叶子节点
return (
<SidebarMenuSubButton asChild>
<Link
to={buildFullPath([...currentPaths, child.path])}
title={child.description}
>
{child.icon}
<span>{child.name}</span>
</Link>
</SidebarMenuSubButton>
);
};
// 递归渲染菜单项
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
const currentPaths = [...parentPaths, tool.path];
if (tool.children) {
// 有子菜单的项目
return (
<SidebarMenuItem key={tool.name}>
<Collapsible
defaultOpen={false}
className="group/collapsible"
>
<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}>
{renderSubMenuContent(child, currentPaths)}
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
// 没有子菜单的项目
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>
<SidebarGroupContent>
<SidebarMenu>
{tools.map((tool) => renderMenuItem(tool))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="flex flex-row justify-between items-center gap-2">
<SidebarMenuButton variant="outline" asChild>
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
</SidebarMenuButton>
</SidebarFooter>
</Sidebar>
);
};

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,38 @@
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "./provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,70 @@
import { useState, type FC } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
const Tool: FC = () => {
const [decoded, setDecoded] = useState<string>("");
const [encoded, setEncoded] = useState<string>("");
const encode = () => {
try {
const encoded64 = btoa(decoded);
setEncoded(encoded64);
setDecoded("");
toast.success("encoded successfully");
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("encoding failed");
}
}
};
const decode = () => {
try {
const decoded64 = atob(encoded);
setDecoded(decoded64);
setEncoded("");
toast.success("decoded successfully");
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("decoding failed");
}
}
};
return (
<div className="h-[50vh] flex flex-row gap-4 pt-[20vh]">
<Textarea
className="flex-1 resize-none"
placeholder="Enter the original text"
value={decoded}
onChange={(e) => setDecoded(e.target.value)}
/>
<div className="flex flex-col gap-2 justify-center">
<Button onClick={encode}>
<ArrowRightIcon className="size-4" />
Encode
</Button>
<Button onClick={decode}>
<ArrowLeftIcon className="size-4" />
Decode
</Button>
</div>
<Textarea
className="flex-1 resize-none"
placeholder="Enter the Base64 encoded text"
value={encoded}
onChange={(e) => setEncoded(e.target.value)}
/>
</div>
);
};
export default Tool;

View File

@@ -1,15 +1,23 @@
import type { ReactNode } from 'react'; import { lazy, type ReactNode, type ComponentType } from 'react';
import { FileJson, Hash } from 'lucide-react' import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
import UUID from './uuid' // 懒加载工具组件
import JSON from './json' const UUID = lazy(() => import('./uuid'))
const JSON = lazy(() => import('./json'))
const Base64 = lazy(() => import('./base64'))
const DNS = lazy(() => import('./network/dns'))
const Ping = lazy(() => import('./network/ping'))
const TCPing = lazy(() => import('./network/tcping'))
const SpeedTest = lazy(() => import('./network/speedtest'))
const IPQuery = lazy(() => import('./network/ipquery'))
export interface Tool { export interface Tool {
path: string; path: string;
name: string; name: string;
icon: ReactNode; icon: ReactNode;
description: string; description: string;
component: ReactNode; component?: ComponentType;
children?: Tool[];
} }
export const tools: Tool[] = [ export const tools: Tool[] = [
@@ -18,13 +26,63 @@ export const tools: Tool[] = [
name: "UUID Generator", name: "UUID Generator",
description: "Generate a UUID", description: "Generate a UUID",
icon: <Hash />, icon: <Hash />,
component: <UUID />, component: UUID,
}, },
{ {
path: "json", path: "json",
name: "JSON Formatter", name: "JSON Formatter",
description: "Format and validate JSON", description: "Format and validate JSON",
icon: <FileJson />, icon: <FileJson />,
component: <JSON />, component: JSON,
} },
{
path: "base64",
name: "Base64 Encoder/Decoder",
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,
},
],
},
]; ];

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 }

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

96
src/hooks/use-seo.ts Normal file
View 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]);
};

View File

@@ -118,3 +118,50 @@
@apply bg-background text-foreground; @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;
}

View File

@@ -1,20 +1,31 @@
import type { FC } from "react" import type { FC } from "react"
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { ThemeProvider } from "@/components/theme/provider"
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/sidebar"; import { AppSidebar } from "@/components/sidebar";
import { ModeToggle } from "@/components/theme/toggle";
export const Layout: FC = () => ( import { useSEO } from "@/hooks/use-seo";
<SidebarProvider>
<AppSidebar /> export const Layout: FC = () => {
<div className="p-4 flex flex-col w-full h-[100vh]"> // 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据
<nav className="flex items-center justify-between"> useSEO();
<SidebarTrigger className="size-10" />
<div role="actions" /> return (
</nav> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<main className="flex-1 overflow-auto p-4"> <SidebarProvider>
<Outlet /> <AppSidebar />
</main> <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden">
</div> <nav className="flex items-center justify-between">
</SidebarProvider> <SidebarTrigger className="size-10" />
); <ModeToggle />
</nav>
<main className="flex-1 overflow-auto p-4 overflow-hidden">
<Outlet />
</main>
</div>
</SidebarProvider>
</ThemeProvider>
);
};

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import './index.css' import './index.css'
import { AppRouter } from './router' import { AppRouter } from './router'
@@ -12,3 +13,21 @@ createRoot(document.getElementById('root')!).render(
<Toaster /> <Toaster />
</StrictMode> </StrictMode>
) )
// 注册 Service Worker
if ('serviceWorker' in navigator && import.meta.env.PROD) {
import('workbox-window').then(({ Workbox }) => {
const wb = new Workbox('/sw.js')
wb.addEventListener('installed', (event) => {
if (event.isUpdate) {
console.log('New service worker installed, reloading page...')
window.location.reload()
}
})
wb.register()
}).catch((error) => {
console.error('Failed to register service worker:', error)
})
}

View File

@@ -1,12 +1,50 @@
import { Suspense, createElement } from "react";
import { import {
createBrowserRouter, createBrowserRouter,
redirect, redirect,
RouterProvider, RouterProvider,
type RouteObject,
} from "react-router-dom"; } from "react-router-dom";
import { tools } from "@/components/tool"; import { tools, type Tool } from "@/components/tool";
import { Layout } from "./layout"; import { Layout } from "./layout";
// 加载中的占位组件
const LoadingFallback = () => (
<div className="flex items-center justify-center h-full">
<div className="text-center flex flex-col items-center gap-3">
<div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-muted"></div>
<div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-sm text-muted-foreground font-medium">Loading...</p>
</div>
</div>
);
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
return tools.map((tool) => {
const route: RouteObject = {
path: tool.path,
};
if (tool.component) {
// 使用 Suspense 包裹懒加载组件
route.element = (
<Suspense fallback={<LoadingFallback />}>
{createElement(tool.component)}
</Suspense>
);
}
if (tool.children && tool.children.length > 0) {
route.children = buildToolRoutes(tool.children);
}
return route;
});
};
// 路由配置 // 路由配置
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -16,19 +54,14 @@ const router = createBrowserRouter([
{ {
path: "tool", path: "tool",
children: [ children: [
...tools.map((tool) => ( ...buildToolRoutes(tools),
{
path: tool.path,
element: tool.component,
}
)),
{ {
index: true, index: true,
loader: () => redirect("/tool/uuid"), loader: () => redirect("/tool/uuid"),
}, },
] ],
}, },
] ],
}, },
{ {
index: true, index: true,

3
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -2,13 +2,102 @@ import path from "path"
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'],
manifest: {
name: 'Lite Kit - Lightweight Online Tools',
short_name: 'Lite Kit',
description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: '/lite.svg',
type: 'image/svg+xml',
sizes: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/ipinfo\.io\/.*/i,
handler: 'CacheFirst', // 改为 CacheFirst优先使用缓存
options: {
cacheName: 'ipinfo-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 // 延长到 1 小时
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// React 核心拆分得更细
if (id.includes('node_modules/react/') && !id.includes('node_modules/react-dom')) {
return 'react-core';
}
if (id.includes('node_modules/react-dom/')) {
return 'react-dom';
}
if (id.includes('node_modules/react-router-dom')) {
return 'react-router';
}
// Radix UI组件
if (id.includes('node_modules/@radix-ui')) {
return 'ui-vendor';
}
// 图标库
if (id.includes('node_modules/lucide-react')) {
return 'icons';
}
// 其他工具库
if (id.includes('node_modules/')) {
return 'vendor';
}
},
},
},
// 启用更激进的压缩
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log'],
// 移除未使用的代码
unused: true,
// 移除死代码
dead_code: true,
},
},
chunkSizeWarningLimit: 500,
},
}) })