Compare commits
	
		
			14 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 24c154a759 | ||
|   | edf87370d9 | ||
|   | ae0f9447ea | ||
|   | 972b6c7f22 | ||
|   | be56d896ca | ||
| 3b31ce9ddf | |||
|   | b97c746d36 | ||
| 9793d96def | |||
|   | a6d9c23179 | ||
|   | 28a86dcbff | ||
| 7cd826b052 | |||
|   | da20e34dc9 | ||
|   | a43b5a96bb | ||
|   | 48aaa262c1 | 
| @@ -36,6 +36,7 @@ jobs: | ||||
|             type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }} | ||||
|        | ||||
|       - name: 构建并推送 Docker 镜像 | ||||
|         id: build | ||||
|         uses: docker/build-push-action@v5 | ||||
|         with: | ||||
|           context: . | ||||
| @@ -46,4 +47,11 @@ jobs: | ||||
|           cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max | ||||
|           # platforms: linux/amd64,linux/arm64 | ||||
|           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 }}"}' | ||||
|  | ||||
|   | ||||
							
								
								
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal 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 | ||||
|  | ||||
| @@ -11,7 +11,8 @@ WORKDIR /app | ||||
| COPY package.json pnpm-lock.yaml ./ | ||||
|  | ||||
| # 安装依赖 | ||||
| RUN pnpm install --frozen-lockfile | ||||
| RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ | ||||
|     pnpm install --frozen-lockfile | ||||
|  | ||||
| # 复制源代码 | ||||
| COPY . . | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| { | ||||
|   "name": "litek", | ||||
|   "private": true, | ||||
|   "version": "0.0.5", | ||||
|   "version": "0.0.11", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "tsc -b && vite build", | ||||
|     "lint": "eslint .", | ||||
|     "preview": "vite preview" | ||||
|     "preview": "vite preview", | ||||
|     "release:patch": "npm version patch && git push --follow-tags", | ||||
|     "release:minor": "npm version minor && git push --follow-tags", | ||||
|     "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", | ||||
|     "@radix-ui/react-slot": "^1.2.3", | ||||
|     "@radix-ui/react-tooltip": "^1.2.8", | ||||
|   | ||||
							
								
								
									
										172
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										172
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -11,9 +11,15 @@ 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) | ||||
|       '@radix-ui/react-dropdown-menu': | ||||
|         specifier: ^2.1.16 | ||||
|         version: 2.1.16(@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-separator': | ||||
|         specifier: ^1.1.7 | ||||
|         version: 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) | ||||
| @@ -320,6 +326,32 @@ 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: | ||||
|       '@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-compose-refs@1.1.2': | ||||
|     resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} | ||||
|     peerDependencies: | ||||
| @@ -351,6 +383,15 @@ packages: | ||||
|       '@types/react-dom': | ||||
|         optional: true | ||||
|  | ||||
|   '@radix-ui/react-direction@1.1.1': | ||||
|     resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} | ||||
|     peerDependencies: | ||||
|       '@types/react': '*' | ||||
|       react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc | ||||
|     peerDependenciesMeta: | ||||
|       '@types/react': | ||||
|         optional: true | ||||
|  | ||||
|   '@radix-ui/react-dismissable-layer@1.1.11': | ||||
|     resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} | ||||
|     peerDependencies: | ||||
| @@ -364,6 +405,19 @@ packages: | ||||
|       '@types/react-dom': | ||||
|         optional: true | ||||
|  | ||||
|   '@radix-ui/react-dropdown-menu@2.1.16': | ||||
|     resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} | ||||
|     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-focus-guards@1.1.3': | ||||
|     resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} | ||||
|     peerDependencies: | ||||
| @@ -395,6 +449,19 @@ packages: | ||||
|       '@types/react': | ||||
|         optional: true | ||||
|  | ||||
|   '@radix-ui/react-menu@2.1.16': | ||||
|     resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} | ||||
|     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-popper@1.2.8': | ||||
|     resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} | ||||
|     peerDependencies: | ||||
| @@ -447,6 +514,19 @@ packages: | ||||
|       '@types/react-dom': | ||||
|         optional: true | ||||
|  | ||||
|   '@radix-ui/react-roving-focus@1.1.11': | ||||
|     resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} | ||||
|     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-separator@1.1.7': | ||||
|     resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} | ||||
|     peerDependencies: | ||||
| @@ -1857,6 +1937,34 @@ 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) | ||||
|       '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(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-slot': 1.2.3(@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-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)': | ||||
|     dependencies: | ||||
|       react: 19.2.0 | ||||
| @@ -1891,6 +1999,12 @@ snapshots: | ||||
|       '@types/react': 19.2.2 | ||||
|       '@types/react-dom': 19.2.2(@types/react@19.2.2) | ||||
|  | ||||
|   '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.0)': | ||||
|     dependencies: | ||||
|       react: 19.2.0 | ||||
|     optionalDependencies: | ||||
|       '@types/react': 19.2.2 | ||||
|  | ||||
|   '@radix-ui/react-dismissable-layer@1.1.11(@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 | ||||
| @@ -1904,6 +2018,21 @@ snapshots: | ||||
|       '@types/react': 19.2.2 | ||||
|       '@types/react-dom': 19.2.2(@types/react@19.2.2) | ||||
|  | ||||
|   '@radix-ui/react-dropdown-menu@2.1.16(@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-menu': 2.1.16(@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) | ||||
|       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-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.0)': | ||||
|     dependencies: | ||||
|       react: 19.2.0 | ||||
| @@ -1928,6 +2057,32 @@ snapshots: | ||||
|     optionalDependencies: | ||||
|       '@types/react': 19.2.2 | ||||
|  | ||||
|   '@radix-ui/react-menu@2.1.16(@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-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) | ||||
|       '@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-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) | ||||
|       '@radix-ui/react-dismissable-layer': 1.1.11(@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-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) | ||||
|       '@radix-ui/react-focus-scope': 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) | ||||
|       '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) | ||||
|       '@radix-ui/react-popper': 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) | ||||
|       '@radix-ui/react-portal': 1.1.9(@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-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-roving-focus': 1.1.11(@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-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) | ||||
|       '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) | ||||
|       aria-hidden: 1.2.6 | ||||
|       react: 19.2.0 | ||||
|       react-dom: 19.2.0(react@19.2.0) | ||||
|       react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) | ||||
|     optionalDependencies: | ||||
|       '@types/react': 19.2.2 | ||||
|       '@types/react-dom': 19.2.2(@types/react@19.2.2) | ||||
|  | ||||
|   '@radix-ui/react-popper@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)': | ||||
|     dependencies: | ||||
|       '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) | ||||
| @@ -1975,6 +2130,23 @@ snapshots: | ||||
|       '@types/react': 19.2.2 | ||||
|       '@types/react-dom': 19.2.2(@types/react@19.2.2) | ||||
|  | ||||
|   '@radix-ui/react-roving-focus@1.1.11(@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-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) | ||||
|       '@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-direction': 1.1.1(@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-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-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) | ||||
|       '@radix-ui/react-use-controllable-state': 1.2.2(@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-separator@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-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) | ||||
|   | ||||
| @@ -1,35 +1,98 @@ | ||||
| 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 = () => ( | ||||
|   <Sidebar> | ||||
|     <SidebarHeader className="text-2xl font-bold flex justify-center items-center"> | ||||
|       Lite Kit | ||||
|     </SidebarHeader> | ||||
|     <SidebarContent> | ||||
|       <SidebarGroup> | ||||
|         <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> | ||||
|             )) | ||||
|           } | ||||
|         </SidebarGroupContent> | ||||
|       </SidebarGroup> | ||||
|     </SidebarContent> | ||||
|     <SidebarFooter> | ||||
|       <a href="mailto:litek@mail.typist.cc">contact us</a> | ||||
|     </SidebarFooter> | ||||
|   </Sidebar> | ||||
| ) | ||||
| 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> | ||||
|           <SidebarGroupContent> | ||||
|             <SidebarMenu> | ||||
|               {tools.map((tool) => renderMenuItem(tool))} | ||||
|             </SidebarMenu> | ||||
|           </SidebarGroupContent> | ||||
|         </SidebarGroup> | ||||
|       </SidebarContent> | ||||
|       <SidebarFooter className="flex flex-row justify-between items-center gap-2"> | ||||
|         <Button variant="link"> | ||||
|           <a href="mailto:litek@mail.typist.cc">need more tools?</a> | ||||
|         </Button> | ||||
|         <ModeToggle /> | ||||
|       </SidebarFooter> | ||||
|     </Sidebar> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal 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; | ||||
|  | ||||
| @@ -1,15 +1,18 @@ | ||||
| import type { ReactNode } 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' | ||||
| 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[] = [ | ||||
| @@ -26,5 +29,55 @@ export const tools: Tool[] = [ | ||||
|     description: "Format and validate JSON", | ||||
|     icon: <FileJson />, | ||||
|     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 />, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
| ]; | ||||
							
								
								
									
										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'; | ||||
|  | ||||
							
								
								
									
										312
									
								
								src/components/tool/network/ipquery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								src/components/tool/network/ipquery.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | ||||
| 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 { | ||||
|       // 使用 ip-api.com (免费,功能较全) | ||||
|       const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip.trim())}?fields=status,message,country,countryCode,region,city,lat,lon,timezone,isp,org,as,proxy,hosting,query`); | ||||
|        | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP error! status: ${response.status}`); | ||||
|       } | ||||
|  | ||||
|       const data = await response.json(); | ||||
|       const endTime = performance.now(); | ||||
|        | ||||
|       setQueryTime(endTime - startTime); | ||||
|  | ||||
|       if (data.status === "fail") { | ||||
|         toast.error(data.message || "Query failed"); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // 转换为统一格式 | ||||
|       const ipData: IPInfo = { | ||||
|         ip: data.query, | ||||
|         city: data.city, | ||||
|         region: data.region, | ||||
|         country: data.country, | ||||
|         countryCode: data.countryCode, | ||||
|         loc: data.lat && data.lon ? `${data.lat},${data.lon}` : undefined, | ||||
|         timezone: data.timezone, | ||||
|         isp: data.isp, | ||||
|         org: data.org, | ||||
|         as: data.as, | ||||
|         proxy: data.proxy, | ||||
|         hosting: data.hosting, | ||||
|       }; | ||||
|  | ||||
|       setIpInfo(ipData); | ||||
|       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; | ||||
|      | ||||
|     if (ipInfo.proxy || ipInfo.hosting) { | ||||
|       return { | ||||
|         level: "High", | ||||
|         color: "text-red-500", | ||||
|         reasons: [ | ||||
|           ipInfo.proxy && "Proxy/VPN detected", | ||||
|           ipInfo.hosting && "Hosting/Datacenter IP", | ||||
|         ].filter(Boolean), | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     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 } | ||||
|  | ||||
							
								
								
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal 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, | ||||
| } | ||||
| @@ -117,4 +117,51 @@ | ||||
|   body { | ||||
|     @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; | ||||
| } | ||||
| @@ -1,20 +1,23 @@ | ||||
| import type { FC } from "react" | ||||
| import { Outlet } from "react-router-dom"; | ||||
|  | ||||
| import { ThemeProvider } from "@/components/theme/provider" | ||||
| import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" | ||||
| import { AppSidebar } from "@/components/sidebar"; | ||||
|  | ||||
| export const Layout: FC = () => ( | ||||
|   <SidebarProvider> | ||||
|     <AppSidebar /> | ||||
|     <div className="p-4 flex flex-col w-full h-[100vh]"> | ||||
|       <nav className="flex items-center justify-between"> | ||||
|         <SidebarTrigger className="size-10" /> | ||||
|         <div role="actions" /> | ||||
|       </nav> | ||||
|       <main className="flex-1 overflow-auto p-4"> | ||||
|         <Outlet /> | ||||
|       </main> | ||||
|     </div> | ||||
|   </SidebarProvider> | ||||
|   <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> | ||||
|     <SidebarProvider> | ||||
|       <AppSidebar /> | ||||
|       <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> | ||||
|         <nav className="flex items-center justify-between"> | ||||
|           <SidebarTrigger className="size-10" /> | ||||
|           <div role="actions" /> | ||||
|         </nav> | ||||
|         <main className="flex-1 overflow-auto p-4 overflow-hidden"> | ||||
|           <Outlet /> | ||||
|         </main> | ||||
|       </div> | ||||
|     </SidebarProvider> | ||||
|   </ThemeProvider> | ||||
| ); | ||||
| @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' | ||||
|  | ||||
| import { Toaster } from '@/components/ui/sonner' | ||||
|  | ||||
|  | ||||
| import './index.css' | ||||
| import { AppRouter } from './router' | ||||
|  | ||||
|   | ||||
| @@ -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