Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a1b68ed2c | ||
|
|
32970acf32 | ||
|
|
812bb8c248 | ||
|
|
09f9e6588f | ||
|
|
10a167febd | ||
|
|
91c0686a46 | ||
|
|
40bfde8e57 | ||
|
|
b4ba7a2219 | ||
|
|
25e42e3af5 | ||
|
|
4398b53ea7 | ||
|
|
3e14bc652f | ||
|
|
aca7e11835 | ||
|
|
782de6e38a | ||
|
|
7646830194 | ||
|
|
59f998e8e3 | ||
|
|
55207beff5 | ||
|
|
e98a344b95 | ||
|
|
d553c3e04c | ||
|
|
e2da2758cc | ||
|
|
3ab70498e6 | ||
|
|
a5ef1a1e70 | ||
|
|
297000f208 |
171
SEO-README.md
171
SEO-README.md
@@ -1,171 +0,0 @@
|
|||||||
# SEO 优化说明
|
|
||||||
|
|
||||||
本项目已完成基础 SEO 优化,以下是已实施的改动。
|
|
||||||
|
|
||||||
## 已完成的优化项
|
|
||||||
|
|
||||||
### 1. HTML Meta 标签优化 ✅
|
|
||||||
|
|
||||||
在 `index.html` 中添加了完整的 SEO 元数据:
|
|
||||||
|
|
||||||
- **基础 Meta 标签**
|
|
||||||
- title, description, keywords
|
|
||||||
- author, theme-color
|
|
||||||
- canonical URL
|
|
||||||
|
|
||||||
- **Open Graph 标签**(社交媒体分享优化)
|
|
||||||
- og:type, og:url, og:title
|
|
||||||
- og:description, og:image, og:site_name
|
|
||||||
|
|
||||||
- **Twitter Card 标签**
|
|
||||||
- twitter:card, twitter:title
|
|
||||||
- twitter:description, twitter:image
|
|
||||||
|
|
||||||
- **结构化数据**(JSON-LD)
|
|
||||||
- Schema.org WebSite 类型标记
|
|
||||||
- 提升搜索引擎理解能力
|
|
||||||
|
|
||||||
### 2. SEO 配置文件 ✅
|
|
||||||
|
|
||||||
- **`public/robots.txt`** - 搜索引擎爬虫规则
|
|
||||||
- 允许所有爬虫访问
|
|
||||||
- 指定 sitemap 位置
|
|
||||||
|
|
||||||
- **`public/sitemap.xml`** - 站点地图(自动生成)
|
|
||||||
- 包含所有工具页面 URL
|
|
||||||
- 设置更新频率和优先级
|
|
||||||
- 通过 `scripts/generate-sitemap.ts` 自动生成
|
|
||||||
|
|
||||||
- **`public/manifest.json`** - PWA 配置
|
|
||||||
- 支持渐进式 Web 应用
|
|
||||||
- 改善移动端体验
|
|
||||||
|
|
||||||
### 3. 构建流程优化 ✅
|
|
||||||
|
|
||||||
- **`package.json`**
|
|
||||||
- 构建时自动生成 sitemap
|
|
||||||
- 添加 tsx 依赖用于运行生成脚本
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
```
|
|
||||||
public/
|
|
||||||
├── robots.txt # 爬虫规则
|
|
||||||
├── sitemap.xml # 站点地图(自动生成)
|
|
||||||
└── manifest.json # PWA 配置
|
|
||||||
|
|
||||||
scripts/
|
|
||||||
└── generate-sitemap.ts # Sitemap 生成脚本
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
```
|
|
||||||
index.html # 添加 SEO meta 标签和结构化数据
|
|
||||||
package.json # 添加 sitemap 生成命令
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
### 开发
|
|
||||||
```bash
|
|
||||||
pnpm install # 安装依赖
|
|
||||||
pnpm dev # 启动开发服务器
|
|
||||||
```
|
|
||||||
|
|
||||||
### 构建
|
|
||||||
```bash
|
|
||||||
pnpm run build # 构建项目(自动生成 sitemap)
|
|
||||||
pnpm run generate:sitemap # 单独生成 sitemap
|
|
||||||
```
|
|
||||||
|
|
||||||
### 部署前检查
|
|
||||||
|
|
||||||
⚠️ **重要:部署前必须更新配置文件中的域名!**
|
|
||||||
|
|
||||||
需要将以下文件中的 `https://litek.typist.cc` 替换为你的实际域名:
|
|
||||||
|
|
||||||
1. **`index.html`**
|
|
||||||
- canonical URL
|
|
||||||
- Open Graph URL 和 image
|
|
||||||
- Twitter Card image
|
|
||||||
- Structured Data URL
|
|
||||||
|
|
||||||
2. **`public/robots.txt`**
|
|
||||||
- Sitemap URL
|
|
||||||
|
|
||||||
3. **`scripts/generate-sitemap.ts`**
|
|
||||||
- BASE_URL 常量
|
|
||||||
|
|
||||||
更新后重新构建:
|
|
||||||
```bash
|
|
||||||
pnpm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## SEO 验证
|
|
||||||
|
|
||||||
### 部署后验证
|
|
||||||
|
|
||||||
1. **提交 sitemap 到 Google Search Console**
|
|
||||||
- 访问 https://search.google.com/search-console
|
|
||||||
- 添加网站资源
|
|
||||||
- 提交 sitemap: `https://你的域名/sitemap.xml`
|
|
||||||
|
|
||||||
2. **测试结构化数据**
|
|
||||||
- 访问 https://validator.schema.org/
|
|
||||||
- 输入网站 URL
|
|
||||||
- 检查是否有错误
|
|
||||||
|
|
||||||
3. **测试 Open Graph 预览**
|
|
||||||
- Facebook: https://developers.facebook.com/tools/debug/
|
|
||||||
- Twitter: https://cards-dev.twitter.com/validator
|
|
||||||
|
|
||||||
4. **性能测试**
|
|
||||||
- Google PageSpeed Insights: https://pagespeed.web.dev/
|
|
||||||
- 目标分数: SEO > 95
|
|
||||||
|
|
||||||
## 可访问的 URL
|
|
||||||
|
|
||||||
部署后,以下 URL 应该可以正常访问:
|
|
||||||
|
|
||||||
- `https://你的域名/` - 主页
|
|
||||||
- `https://你的域名/robots.txt` - 爬虫规则
|
|
||||||
- `https://你的域名/sitemap.xml` - 站点地图
|
|
||||||
- `https://你的域名/manifest.json` - PWA 配置
|
|
||||||
- `https://你的域名/tool/uuid` - UUID 工具
|
|
||||||
- `https://你的域名/tool/json` - JSON 工具
|
|
||||||
- `https://你的域名/tool/base64` - Base64 工具
|
|
||||||
- `https://你的域名/tool/network/dns` - DNS 工具
|
|
||||||
- ... 其他工具页面
|
|
||||||
|
|
||||||
## 添加新工具时更新 SEO
|
|
||||||
|
|
||||||
当添加新工具时,需要更新 `scripts/generate-sitemap.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const tools = [
|
|
||||||
// 现有工具...
|
|
||||||
{ path: '/tool/你的新工具', priority: '0.9' },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
然后重新构建项目。
|
|
||||||
|
|
||||||
## SEO 效果预期
|
|
||||||
|
|
||||||
- **1-2 周**:搜索引擎开始索引页面
|
|
||||||
- **1-2 月**:主要关键词开始有排名
|
|
||||||
- **3-6 月**:稳定的搜索排名和自然流量增长
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- React 19 + TypeScript
|
|
||||||
- Vite (Rolldown)
|
|
||||||
- React Router v7
|
|
||||||
- Radix UI + Tailwind CSS 4
|
|
||||||
- Nginx
|
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
需要更多工具或有建议?联系:litek@mail.typist.cc
|
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/lite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/lite.svg" />
|
||||||
<link rel="canonical" href="https://litek.typist.cc/" />
|
|
||||||
|
<!-- DNS Prefetch & Preconnect for external APIs -->
|
||||||
|
<link rel="dns-prefetch" href="https://ipinfo.io">
|
||||||
|
<link rel="preconnect" href="https://ipinfo.io" crossorigin>
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
@@ -56,5 +59,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|
||||||
|
<!-- Cloudflare Web Analytics -->
|
||||||
|
<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "2aecdc025eb043bc89ce931b54a80054"}'></script>
|
||||||
|
<!-- End Cloudflare Web Analytics -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "litek",
|
"name": "litek",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.15",
|
"version": "0.0.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run generate:sitemap && tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"generate:sitemap": "tsx scripts/generate-sitemap.ts",
|
"generate:sitemap": "tsx scripts/generate-sitemap.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -44,11 +44,14 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"terser": "^5.44.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.1.14",
|
||||||
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
2811
pnpm-lock.yaml
generated
2811
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar";
|
import { 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 { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { tools, type Tool } from "@/components/tool";
|
import { tools, type Tool } from "@/components/tool";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { ModeToggle } from "@/components/theme/toggle";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
export const AppSidebar = () => {
|
export const AppSidebar = () => {
|
||||||
// 递归构建完整路径
|
// 递归构建完整路径
|
||||||
@@ -13,6 +13,46 @@ export const AppSidebar = () => {
|
|||||||
return `/tool/${pathSegments.join("/")}`;
|
return `/tool/${pathSegments.join("/")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 递归渲染子菜单内容
|
||||||
|
const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => {
|
||||||
|
if (child.children) {
|
||||||
|
// 子菜单内的可折叠项
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={false} className="group/collapsible">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuSubButton>
|
||||||
|
{child.icon}
|
||||||
|
<span>{child.name}</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{child.children.map((subChild) => (
|
||||||
|
<SidebarMenuSubItem key={subChild.name}>
|
||||||
|
{renderSubMenuContent(subChild, [...currentPaths, child.path])}
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 叶子节点
|
||||||
|
return (
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<Link
|
||||||
|
to={buildFullPath([...currentPaths, child.path])}
|
||||||
|
title={child.description}
|
||||||
|
>
|
||||||
|
{child.icon}
|
||||||
|
<span>{child.name}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 递归渲染菜单项
|
// 递归渲染菜单项
|
||||||
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
|
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
|
||||||
const currentPaths = [...parentPaths, tool.path];
|
const currentPaths = [...parentPaths, tool.path];
|
||||||
@@ -20,12 +60,11 @@ export const AppSidebar = () => {
|
|||||||
if (tool.children) {
|
if (tool.children) {
|
||||||
// 有子菜单的项目
|
// 有子菜单的项目
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<SidebarMenuItem key={tool.name}>
|
||||||
key={tool.name}
|
<Collapsible
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
className="group/collapsible"
|
className="group/collapsible"
|
||||||
>
|
>
|
||||||
<SidebarMenuItem>
|
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton tooltip={tool.description}>
|
<SidebarMenuButton tooltip={tool.description}>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
@@ -37,25 +76,13 @@ export const AppSidebar = () => {
|
|||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
{tool.children.map((child) => (
|
{tool.children.map((child) => (
|
||||||
<SidebarMenuSubItem key={child.name}>
|
<SidebarMenuSubItem key={child.name}>
|
||||||
{child.children ? (
|
{renderSubMenuContent(child, currentPaths)}
|
||||||
renderMenuItem(child, currentPaths)
|
|
||||||
) : (
|
|
||||||
<SidebarMenuSubButton asChild>
|
|
||||||
<Link
|
|
||||||
to={buildFullPath([...currentPaths, child.path])}
|
|
||||||
title={child.description}
|
|
||||||
>
|
|
||||||
{child.icon}
|
|
||||||
<span>{child.name}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</SidebarMenuItem>
|
</Collapsible>
|
||||||
</Collapsible>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +115,9 @@ export const AppSidebar = () => {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="flex flex-row justify-between items-center gap-2">
|
<SidebarFooter className="flex flex-row justify-between items-center gap-2">
|
||||||
<Button variant="link">
|
<SidebarMenuButton variant="outline" asChild>
|
||||||
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
|
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
|
||||||
</Button>
|
</SidebarMenuButton>
|
||||||
<ModeToggle />
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { lazy, type ReactNode, type ComponentType } from 'react';
|
||||||
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
|
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
import UUID from './uuid'
|
// 懒加载工具组件
|
||||||
import JSON from './json'
|
const UUID = lazy(() => import('./uuid'))
|
||||||
import Base64 from './base64'
|
const JSON = lazy(() => import('./json'))
|
||||||
import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network'
|
const Base64 = lazy(() => import('./base64'))
|
||||||
|
const DNS = lazy(() => import('./network/dns'))
|
||||||
|
const Ping = lazy(() => import('./network/ping'))
|
||||||
|
const TCPing = lazy(() => import('./network/tcping'))
|
||||||
|
const SpeedTest = lazy(() => import('./network/speedtest'))
|
||||||
|
const IPQuery = lazy(() => import('./network/ipquery'))
|
||||||
|
|
||||||
export interface Tool {
|
export interface Tool {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
description: string;
|
description: string;
|
||||||
component?: ReactNode;
|
component?: ComponentType;
|
||||||
children?: Tool[];
|
children?: Tool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,21 +26,21 @@ export const tools: Tool[] = [
|
|||||||
name: "UUID Generator",
|
name: "UUID Generator",
|
||||||
description: "Generate a UUID",
|
description: "Generate a UUID",
|
||||||
icon: <Hash />,
|
icon: <Hash />,
|
||||||
component: <UUID />,
|
component: UUID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "json",
|
path: "json",
|
||||||
name: "JSON Formatter",
|
name: "JSON Formatter",
|
||||||
description: "Format and validate JSON",
|
description: "Format and validate JSON",
|
||||||
icon: <FileJson />,
|
icon: <FileJson />,
|
||||||
component: <JSON />,
|
component: JSON,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "base64",
|
path: "base64",
|
||||||
name: "Base64 Encoder/Decoder",
|
name: "Base64 Encoder/Decoder",
|
||||||
description: "Encode and decode Base64",
|
description: "Encode and decode Base64",
|
||||||
icon: <Binary />,
|
icon: <Binary />,
|
||||||
component: <Base64 />,
|
component: Base64,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "network",
|
path: "network",
|
||||||
@@ -48,35 +53,35 @@ export const tools: Tool[] = [
|
|||||||
name: "DNS Lookup",
|
name: "DNS Lookup",
|
||||||
description: "DNS query tool",
|
description: "DNS query tool",
|
||||||
icon: <Globe />,
|
icon: <Globe />,
|
||||||
component: <DNS />,
|
component: DNS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "ping",
|
path: "ping",
|
||||||
name: "Ping",
|
name: "Ping",
|
||||||
description: "Ping test tool",
|
description: "Ping test tool",
|
||||||
icon: <Activity />,
|
icon: <Activity />,
|
||||||
component: <Ping />,
|
component: Ping,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "tcping",
|
path: "tcping",
|
||||||
name: "TCPing",
|
name: "TCPing",
|
||||||
description: "TCP port connectivity test",
|
description: "TCP port connectivity test",
|
||||||
icon: <Wifi />,
|
icon: <Wifi />,
|
||||||
component: <TCPing />,
|
component: TCPing,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "speedtest",
|
path: "speedtest",
|
||||||
name: "Speed Test",
|
name: "Speed Test",
|
||||||
description: "Website speed test",
|
description: "Website speed test",
|
||||||
icon: <Gauge />,
|
icon: <Gauge />,
|
||||||
component: <SpeedTest />,
|
component: SpeedTest,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "ipquery",
|
path: "ipquery",
|
||||||
name: "IP Query",
|
name: "IP Query",
|
||||||
description: "Query IP location, quality and risk info",
|
description: "Query IP location, quality and risk info",
|
||||||
icon: <MapPin />,
|
icon: <MapPin />,
|
||||||
component: <IPQuery />,
|
component: IPQuery,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export { default as DNS } from './dns';
|
|
||||||
export { default as Ping } from './ping';
|
|
||||||
export { default as TCPing } from './tcping';
|
|
||||||
export { default as SpeedTest } from './speedtest';
|
|
||||||
export { default as IPQuery } from './ipquery';
|
|
||||||
|
|
||||||
@@ -1,22 +1,94 @@
|
|||||||
import { type FC } from "react";
|
import { type FC, useState } from "react";
|
||||||
|
import { RefreshCw, Copy } from "lucide-react";
|
||||||
import * as uuid from 'uuid'
|
import * as uuid from 'uuid'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface IDGeneratorProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDGenerator: FC<IDGeneratorProps> = ({ label, value, onRegenerate }) => {
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast(`${label} has been copied to clipboard`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Copy failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-medium">{label}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm break-all max-w-[400px]">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRegenerate}
|
||||||
|
title="Regenerate"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Tool: FC = () => {
|
const Tool: FC = () => {
|
||||||
|
const [uuidV1, setUuidV1] = useState(() => uuid.v1());
|
||||||
|
const [uuidV4, setUuidV4] = useState(() => uuid.v4());
|
||||||
|
const [uuidV6, setUuidV6] = useState(() => uuid.v6());
|
||||||
|
const [uuidV7, setUuidV7] = useState(() => uuid.v7());
|
||||||
|
const [nanoId, setNanoId] = useState(() => nanoid());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="text-sm text-muted-foreground">Refresh the page to generate new UUID</span>
|
<span className="text-sm text-muted-foreground">Click the refresh button to regenerate the corresponding ID</span>
|
||||||
<label>UUID Version 1</label>
|
|
||||||
<span>{uuid.v1()}</span>
|
<IDGenerator
|
||||||
<label>UUID Version 4</label>
|
label="UUID Version 1"
|
||||||
<span>{uuid.v4()}</span>
|
value={uuidV1}
|
||||||
<label>UUID Version 6</label>
|
onRegenerate={() => setUuidV1(uuid.v1())}
|
||||||
<span>{uuid.v6()}</span>
|
/>
|
||||||
<label>UUID Version 7</label>
|
|
||||||
<span>{uuid.v7()}</span>
|
<IDGenerator
|
||||||
<label>Nano ID</label>
|
label="UUID Version 4"
|
||||||
<span>{nanoid()}</span>
|
value={uuidV4}
|
||||||
|
onRegenerate={() => setUuidV4(uuid.v4())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IDGenerator
|
||||||
|
label="UUID Version 6"
|
||||||
|
value={uuidV6}
|
||||||
|
onRegenerate={() => setUuidV6(uuid.v6())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IDGenerator
|
||||||
|
label="UUID Version 7"
|
||||||
|
value={uuidV7}
|
||||||
|
onRegenerate={() => setUuidV7(uuid.v7())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IDGenerator
|
||||||
|
label="Nano ID"
|
||||||
|
value={nanoId}
|
||||||
|
onRegenerate={() => setNanoId(nanoid())}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
96
src/hooks/use-seo.ts
Normal file
96
src/hooks/use-seo.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface UseSEOOptions {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO Hook - 动态更新页面 SEO 元数据
|
||||||
|
*
|
||||||
|
* @param options - SEO 配置选项
|
||||||
|
* @param options.title - 页面标题(可选)
|
||||||
|
* @param options.description - 页面描述(可选)
|
||||||
|
* @param options.baseUrl - 网站基础 URL,默认为 https://litek.typist.cc
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 在组件中使用
|
||||||
|
* useSEO({
|
||||||
|
* title: 'UUID Generator',
|
||||||
|
* description: 'Free online UUID generator tool'
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useSEO = (options: UseSEOOptions = {}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
baseUrl = 'https://litek.typist.cc'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 构建当前页面的完整 URL
|
||||||
|
const canonicalUrl = `${baseUrl}${location.pathname}`;
|
||||||
|
|
||||||
|
// 更新或创建 canonical 链接
|
||||||
|
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement;
|
||||||
|
if (!canonical) {
|
||||||
|
canonical = document.createElement('link');
|
||||||
|
canonical.setAttribute('rel', 'canonical');
|
||||||
|
document.head.appendChild(canonical);
|
||||||
|
}
|
||||||
|
canonical.setAttribute('href', canonicalUrl);
|
||||||
|
|
||||||
|
// 更新页面标题
|
||||||
|
if (title) {
|
||||||
|
document.title = `${title} - Lite Kit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 meta description
|
||||||
|
if (description) {
|
||||||
|
let metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement;
|
||||||
|
if (metaDescription) {
|
||||||
|
metaDescription.setAttribute('content', description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Open Graph URL
|
||||||
|
let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
|
||||||
|
if (ogUrl) {
|
||||||
|
ogUrl.setAttribute('content', canonicalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Open Graph Title
|
||||||
|
if (title) {
|
||||||
|
let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
|
||||||
|
if (ogTitle) {
|
||||||
|
ogTitle.setAttribute('content', `${title} - Lite Kit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Twitter Card Title
|
||||||
|
let twitterTitle = document.querySelector('meta[name="twitter:title"]') as HTMLMetaElement;
|
||||||
|
if (twitterTitle) {
|
||||||
|
twitterTitle.setAttribute('content', `${title} - Lite Kit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Open Graph Description
|
||||||
|
if (description) {
|
||||||
|
let ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement;
|
||||||
|
if (ogDescription) {
|
||||||
|
ogDescription.setAttribute('content', description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Twitter Card Description
|
||||||
|
let twitterDescription = document.querySelector('meta[name="twitter:description"]') as HTMLMetaElement;
|
||||||
|
if (twitterDescription) {
|
||||||
|
twitterDescription.setAttribute('content', description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.pathname, title, description, baseUrl]);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,20 +4,28 @@ import { Outlet } from "react-router-dom";
|
|||||||
import { ThemeProvider } from "@/components/theme/provider"
|
import { ThemeProvider } from "@/components/theme/provider"
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
import { AppSidebar } from "@/components/sidebar";
|
import { AppSidebar } from "@/components/sidebar";
|
||||||
|
import { ModeToggle } from "@/components/theme/toggle";
|
||||||
|
|
||||||
export const Layout: FC = () => (
|
import { useSEO } from "@/hooks/use-seo";
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
|
||||||
<SidebarProvider>
|
export const Layout: FC = () => {
|
||||||
<AppSidebar />
|
// 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据
|
||||||
<div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden">
|
useSEO();
|
||||||
<nav className="flex items-center justify-between">
|
|
||||||
<SidebarTrigger className="size-10" />
|
return (
|
||||||
<div role="actions" />
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
</nav>
|
<SidebarProvider>
|
||||||
<main className="flex-1 overflow-auto p-4 overflow-hidden">
|
<AppSidebar />
|
||||||
<Outlet />
|
<div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden">
|
||||||
</main>
|
<nav className="flex items-center justify-between">
|
||||||
</div>
|
<SidebarTrigger className="size-10" />
|
||||||
</SidebarProvider>
|
<ModeToggle />
|
||||||
</ThemeProvider>
|
</nav>
|
||||||
);
|
<main className="flex-1 overflow-auto p-4 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/main.tsx
18
src/main.tsx
@@ -13,3 +13,21 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 注册 Service Worker
|
||||||
|
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||||
|
import('workbox-window').then(({ Workbox }) => {
|
||||||
|
const wb = new Workbox('/sw.js')
|
||||||
|
|
||||||
|
wb.addEventListener('installed', (event) => {
|
||||||
|
if (event.isUpdate) {
|
||||||
|
console.log('New service worker installed, reloading page...')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wb.register()
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to register service worker:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense, createElement } from "react";
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
redirect,
|
redirect,
|
||||||
@@ -8,6 +9,19 @@ import {
|
|||||||
import { tools, type Tool } from "@/components/tool";
|
import { tools, type Tool } from "@/components/tool";
|
||||||
import { Layout } from "./layout";
|
import { Layout } from "./layout";
|
||||||
|
|
||||||
|
// 加载中的占位组件
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center flex flex-col items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="h-12 w-12 rounded-full border-4 border-muted"></div>
|
||||||
|
<div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
|
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
const route: RouteObject = {
|
const route: RouteObject = {
|
||||||
@@ -15,7 +29,12 @@ const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tool.component) {
|
if (tool.component) {
|
||||||
route.element = tool.component;
|
// 使用 Suspense 包裹懒加载组件
|
||||||
|
route.element = (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
{createElement(tool.component)}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tool.children && tool.children.length > 0) {
|
if (tool.children && tool.children.length > 0) {
|
||||||
|
|||||||
3
src/vite-env.d.ts
vendored
Normal file
3
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|
||||||
@@ -2,13 +2,102 @@ import path from "path"
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Lite Kit - Lightweight Online Tools',
|
||||||
|
short_name: 'Lite Kit',
|
||||||
|
description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more',
|
||||||
|
theme_color: '#000000',
|
||||||
|
background_color: '#000000',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/lite.svg',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
sizes: 'any'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/ipinfo\.io\/.*/i,
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: 'ipinfo-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 10,
|
||||||
|
maxAgeSeconds: 60 * 60 // 延长到 1 小时
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
skipWaiting: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: (id) => {
|
||||||
|
// React 核心拆分得更细
|
||||||
|
if (id.includes('node_modules/react/') && !id.includes('node_modules/react-dom')) {
|
||||||
|
return 'react-core';
|
||||||
|
}
|
||||||
|
if (id.includes('node_modules/react-dom/')) {
|
||||||
|
return 'react-dom';
|
||||||
|
}
|
||||||
|
if (id.includes('node_modules/react-router-dom')) {
|
||||||
|
return 'react-router';
|
||||||
|
}
|
||||||
|
// Radix UI组件
|
||||||
|
if (id.includes('node_modules/@radix-ui')) {
|
||||||
|
return 'ui-vendor';
|
||||||
|
}
|
||||||
|
// 图标库
|
||||||
|
if (id.includes('node_modules/lucide-react')) {
|
||||||
|
return 'icons';
|
||||||
|
}
|
||||||
|
// 其他工具库
|
||||||
|
if (id.includes('node_modules/')) {
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 启用更激进的压缩
|
||||||
|
minify: 'terser',
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
pure_funcs: ['console.log'],
|
||||||
|
// 移除未使用的代码
|
||||||
|
unused: true,
|
||||||
|
// 移除死代码
|
||||||
|
dead_code: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 500,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user