initial release

This commit is contained in:
root
2026-06-03 09:23:53 +08:00
commit cbfaacb466
26 changed files with 3962 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.git/
*.local
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+38
View File
@@ -0,0 +1,38 @@
# web-tool
React 19 + TypeScript 6 + Vite 8 + Tailwind CSS 4.
## Commands
```bash
npm run dev # dev server
npm run build # tsc -b && vite build (typecheck first, then bundle)
npm run lint # eslint .
npm run preview # vite preview (serve built dist/)
```
## Architecture
- **Entry**: `src/main.tsx``src/App.tsx`
- **Tools**: 6 single-file tools in `src/tools/` (JsonTool, Base64Tool, UrlTool, TimestampTool, HtmlEntitiesTool, CaseConverterTool)
- **Nav**: sidebar in `src/components/Sidebar.tsx`; add a new tool by importing in `App.tsx` and registering in `tools` record
- **Styling**: Tailwind CSS v4 — uses `@import "tailwindcss"` (NOT `@tailwind` directives)
## Conventions
- `verbatimModuleSyntax` is on — use `import type` for type-only imports
- `erasableSyntaxOnly` is on — no enums, no namespaces, no parameter properties
- Chinese (zh-CN) labels only, no i18n
## Docker
```bash
docker compose up -d
```
Multi-stage build (node:22-alpine → nginx). Serves via Traefik behind `tool.linkerhand.cc`. Requires external `traefik-net` network.
## Gotchas
- `npm run build` runs `tsc -b` first; fix type errors before expecting a build to succeed
- No test framework or test scripts; nothing to run for verification beyond build + lint
+14
View File
@@ -0,0 +1,14 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Nginx stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+18
View File
@@ -0,0 +1,18 @@
services:
web-tool:
build: .
container_name: web-tool
restart: unless-stopped
networks:
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.web-tool.rule=Host(`tool.linkerhand.cc`)"
- "traefik.http.routers.web-tool.entrypoints=websecure"
- "traefik.http.routers.web-tool.tls=true"
- "traefik.http.routers.web-tool.tls.certresolver=leresolver"
- "traefik.http.services.web-tool.loadbalancer.server.port=80"
networks:
traefik-net:
external: true
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToolBox - 在线开发工具</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧰</text></svg>">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
# Rate limiting: 10 requests per second per IP (burst 20)
limit_req_zone $binary_remote_addr zone=web:10m rate=10r/s;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Apply rate limiting to all requests
limit_req zone=web burst=20 nodelay;
limit_req_status 429;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
error_page 429 /429.html;
location = /429.html {
internal;
default_type text/html;
return 429 '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>429 Too Many Requests</title><style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center}h1{font-size:3rem;margin:0;color:#ef4444}p{color:#94a3b8}</style></head><body><div><h1>429</h1><p>请求过于频繁,请稍后再试</p><p style="font-size:12px;color:#64748b">Rate limit: 10 requests/second per IP</p></div></body></html>';
}
}
+3054
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "web-tool",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react'
import Sidebar from './components/Sidebar'
import JsonTool from './tools/JsonTool'
import Base64Tool from './tools/Base64Tool'
import UrlTool from './tools/UrlTool'
import TimestampTool from './tools/TimestampTool'
import HtmlEntitiesTool from './tools/HtmlEntitiesTool'
import CaseConverterTool from './tools/CaseConverterTool'
export type ToolId = 'json' | 'base64' | 'url' | 'timestamp' | 'html-entities' | 'case-converter'
const tools: Record<ToolId, { label: string; component: React.FC }> = {
'json': { label: 'JSON 格式化', component: JsonTool },
'base64': { label: 'Base64 编解码', component: Base64Tool },
'url': { label: 'URL 编解码', component: UrlTool },
'timestamp': { label: '时间戳转换', component: TimestampTool },
'html-entities': { label: 'HTML 实体转义', component: HtmlEntitiesTool },
'case-converter': { label: '大小写转换', component: CaseConverterTool },
}
function App() {
const [activeTool, setActiveTool] = useState<ToolId>('json')
const ActiveComponent = tools[activeTool].component
return (
<div className="flex h-screen bg-gray-950 text-gray-100">
<Sidebar tools={tools} activeTool={activeTool} onSelect={setActiveTool} />
<main className="flex-1 flex flex-col overflow-hidden">
<header className="h-14 flex items-center px-6 border-b border-gray-800 shrink-0">
<h1 className="text-lg font-semibold">{tools[activeTool].label}</h1>
</header>
<div className="flex-1 overflow-auto p-6">
<ActiveComponent />
</div>
</main>
</div>
)
}
export default App
+51
View File
@@ -0,0 +1,51 @@
import type { ToolId } from '../App'
interface ToolEntry {
label: string
component: React.FC
}
interface SidebarProps {
tools: Record<ToolId, ToolEntry>
activeTool: ToolId
onSelect: (id: ToolId) => void
}
const iconMap: Record<ToolId, string> = {
'json': '{}',
'base64': 'B64',
'url': 'URL',
'timestamp': '⏱',
'html-entities': '&',
'case-converter': 'Aa',
}
export default function Sidebar({ tools, activeTool, onSelect }: SidebarProps) {
return (
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<div className="h-14 flex items-center px-4 border-b border-gray-800 shrink-0">
<span className="text-xl mr-2">🧰</span>
<span className="font-bold text-base">ToolBox</span>
</div>
<nav className="flex-1 overflow-auto py-2">
{(Object.keys(tools) as ToolId[]).map((id) => (
<button
key={id}
onClick={() => onSelect(id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
activeTool === id
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-500'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
}`}
>
<span className="font-mono text-xs w-8 text-center">{iconMap[id]}</span>
{tools[id].label}
</button>
))}
</nav>
<div className="p-3 text-xs text-gray-500 border-t border-gray-800 text-center">
线
</div>
</aside>
)
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+71
View File
@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react'
export default function Base64Tool() {
const [input, setInput] = useState('')
const [output, setOutput] = useState('')
const [mode, setMode] = useState<'encode' | 'decode'>('encode')
const handleEncode = useCallback(() => {
try {
setOutput(btoa(input))
} catch {
setOutput('编码失败,请检查输入')
}
}, [input])
const handleDecode = useCallback(() => {
try {
setOutput(atob(input))
} catch {
setOutput('解码失败,请检查输入是否为有效 Base64')
}
}, [input])
const handleProcess = useCallback(() => {
if (mode === 'encode') handleEncode()
else handleDecode()
}, [mode, handleEncode, handleDecode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(output)
}, [output])
return (
<div className="h-full flex flex-col gap-4">
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'encode'} onChange={() => setMode('encode')} className="accent-blue-500" />
<span className="text-sm"> (Encode)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'decode'} onChange={() => setMode('decode')} className="accent-blue-500" />
<span className="text-sm"> (Decode)</span>
</label>
</div>
<div className="flex-1 flex flex-col">
<label className="text-sm text-gray-400 mb-1">{mode === 'encode' ? '输入文本' : '输入 Base64'}</label>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none focus:border-blue-500"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'encode' ? '输入要编码的文本...' : '输入 Base64 字符串...'}
/>
</div>
<button onClick={handleProcess} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors w-fit">
{mode === 'encode' ? '编码 →' : '解码 →'}
</button>
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-gray-400"></label>
{output && <button onClick={handleCopy} className="text-xs text-blue-400 hover:text-blue-300"></button>}
</div>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none"
value={output}
readOnly
placeholder="结果..."
/>
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useCallback } from 'react'
const cases = [
{ id: 'upper', label: '大写 UPPER CASE' },
{ id: 'lower', label: '小写 lower case' },
{ id: 'title', label: '首字母大写 Title Case' },
{ id: 'camel', label: '驼峰 camelCase' },
{ id: 'pascal', label: '帕斯卡 PascalCase' },
{ id: 'snake', label: '下划线 snake_case' },
{ id: 'kebab', label: '短横线 kebab-case' },
] as const
type CaseId = typeof cases[number]['id']
function convert(text: string, type: CaseId): string {
// Split into words
const words = text
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(/[\s_\-]+/)
.filter(Boolean)
switch (type) {
case 'upper':
return text.toUpperCase()
case 'lower':
return text.toLowerCase()
case 'title':
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ')
case 'camel':
return words.map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('')
case 'pascal':
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('')
case 'snake':
return words.map(w => w.toLowerCase()).join('_')
case 'kebab':
return words.map(w => w.toLowerCase()).join('-')
}
}
export default function CaseConverterTool() {
const [input, setInput] = useState('')
const [outputs, setOutputs] = useState<Record<string, string>>({})
const handleConvert = useCallback(() => {
if (!input.trim()) return
const result: Record<string, string> = {}
for (const c of cases) {
result[c.id] = convert(input, c.id)
}
setOutputs(result)
}, [input])
const copy = useCallback((text: string) => {
navigator.clipboard.writeText(text)
}, [])
return (
<div className="h-full flex flex-col gap-4">
<div className="flex-1 flex flex-col">
<label className="text-sm text-gray-400 mb-1"></label>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none focus:border-blue-500"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入要转换的文本..."
/>
</div>
<button onClick={handleConvert} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors w-fit"></button>
{Object.keys(outputs).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{cases.map((c) => (
<div key={c.id} className="bg-gray-900 border border-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-400">{c.label}</span>
<button onClick={() => copy(outputs[c.id])} className="text-xs text-blue-400 hover:text-blue-300"></button>
</div>
<code className="text-sm font-mono break-all">{outputs[c.id]}</code>
</div>
))}
</div>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, useCallback } from 'react'
export default function HtmlEntitiesTool() {
const [input, setInput] = useState('')
const [output, setOutput] = useState('')
const [mode, setMode] = useState<'encode' | 'decode'>('encode')
const handleEncode = useCallback(() => {
const div = document.createElement('div')
div.appendChild(document.createTextNode(input))
setOutput(div.innerHTML)
}, [input])
const handleDecode = useCallback(() => {
const div = document.createElement('div')
div.innerHTML = input
setOutput(div.textContent || div.innerText || '')
}, [input])
const handleProcess = useCallback(() => {
if (mode === 'encode') handleEncode()
else handleDecode()
}, [mode, handleEncode, handleDecode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(output)
}, [output])
return (
<div className="h-full flex flex-col gap-4">
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'encode'} onChange={() => setMode('encode')} className="accent-blue-500" />
<span className="text-sm"> (Escape)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'decode'} onChange={() => setMode('decode')} className="accent-blue-500" />
<span className="text-sm"> (Unescape)</span>
</label>
</div>
<div className="flex-1 flex flex-col">
<label className="text-sm text-gray-400 mb-1"></label>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none focus:border-blue-500"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'encode' ? '输入文本(如 <div>...' : '输入 HTML 实体(如 &lt;div&gt;...'}
/>
</div>
<button onClick={handleProcess} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors w-fit">
{mode === 'encode' ? '转义 →' : '反转义 →'}
</button>
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-gray-400"></label>
{output && <button onClick={handleCopy} className="text-xs text-blue-400 hover:text-blue-300"></button>}
</div>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none"
value={output}
readOnly
placeholder="结果..."
/>
</div>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, useCallback } from 'react'
function tryFormatJson(text: string): string {
try {
return JSON.stringify(JSON.parse(text), null, 2)
} catch {
return ''
}
}
function tryMinifyJson(text: string): string {
try {
return JSON.stringify(JSON.parse(text))
} catch {
return ''
}
}
export default function JsonTool() {
const [input, setInput] = useState('')
const [output, setOutput] = useState('')
const [error, setError] = useState('')
const handleFormat = useCallback(() => {
if (!input.trim()) return
const result = tryFormatJson(input)
if (result) {
setOutput(result)
setError('')
} else {
setError('JSON 格式无效,请检查输入')
}
}, [input])
const handleMinify = useCallback(() => {
if (!input.trim()) return
const result = tryMinifyJson(input)
if (result) {
setOutput(result)
setError('')
} else {
setError('JSON 格式无效,请检查输入')
}
}, [input])
const handleEscape = useCallback(() => {
setInput(JSON.stringify(input))
}, [input])
const handleUnescape = useCallback(() => {
try {
setInput(JSON.parse(input))
} catch {
setError('无法解析转义字符串')
}
}, [input])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(output)
}, [output])
return (
<div className="h-full flex flex-col gap-4">
<div className="flex-1 flex flex-col">
<label className="text-sm text-gray-400 mb-1"> JSON</label>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none focus:border-blue-500"
value={input}
onChange={(e) => { setInput(e.target.value); setError('') }}
placeholder='{"key": "value"}'
/>
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={handleFormat} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors"></button>
<button onClick={handleMinify} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm transition-colors"></button>
<button onClick={handleEscape} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm transition-colors"></button>
<button onClick={handleUnescape} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm transition-colors"></button>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-gray-400"></label>
{output && (
<button onClick={handleCopy} className="text-xs text-blue-400 hover:text-blue-300"></button>
)}
</div>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none"
value={output}
readOnly
placeholder="结果..."
/>
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useState, useCallback } from 'react'
function timestampToDate(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
}
function dateToTimestamp(dateStr: string): number {
return Math.floor(new Date(dateStr).getTime() / 1000)
}
export default function TimestampTool() {
const [timestamp, setTimestamp] = useState('')
const [dateStr, setDateStr] = useState('')
const [tsResult, setTsResult] = useState('')
const [dateResult, setDateResult] = useState('')
const [now, setNow] = useState(Math.floor(Date.now() / 1000))
const handleTsToDate = useCallback(() => {
const ts = parseInt(timestamp, 10)
if (isNaN(ts)) {
setTsResult('请输入有效的时间戳')
return
}
setTsResult(timestampToDate(ts))
}, [timestamp])
const handleDateToTs = useCallback(() => {
if (!dateStr) {
setDateResult('请选择日期时间')
return
}
const ts = dateToTimestamp(dateStr)
setDateResult(String(ts))
}, [dateStr])
const refreshNow = useCallback(() => {
const n = Math.floor(Date.now() / 1000)
setNow(n)
}, [])
const copyToTsInput = useCallback(() => {
setTimestamp(String(now))
}, [now])
return (
<div className="h-full flex flex-col gap-6">
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<div className="text-sm text-gray-400 mb-2"></div>
<div className="flex items-center gap-3">
<code className="text-lg font-mono text-blue-400">{now}</code>
<button onClick={refreshNow} className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs transition-colors"></button>
<button onClick={copyToTsInput} className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs transition-colors"></button>
<span className="text-sm text-gray-400">{timestampToDate(now)}</span>
</div>
</div>
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<label className="text-sm text-gray-400 mb-2 block"> </label>
<div className="flex gap-2">
<input
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 font-mono text-sm outline-none focus:border-blue-500"
value={timestamp}
onChange={(e) => setTimestamp(e.target.value)}
placeholder="输入时间戳(秒)"
/>
<button onClick={handleTsToDate} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors shrink-0"></button>
</div>
{tsResult && <p className="mt-2 text-sm font-mono text-green-400">{tsResult}</p>}
</div>
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<label className="text-sm text-gray-400 mb-2 block"> </label>
<div className="flex gap-2">
<input
type="datetime-local"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-500 [color-scheme:dark]"
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
/>
<button onClick={handleDateToTs} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors shrink-0"></button>
</div>
{dateResult && <p className="mt-2 text-sm font-mono text-green-400">{dateResult}</p>}
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState, useCallback } from 'react'
export default function UrlTool() {
const [input, setInput] = useState('')
const [output, setOutput] = useState('')
const [mode, setMode] = useState<'encode' | 'decode'>('encode')
const handleProcess = useCallback(() => {
try {
if (mode === 'encode') {
setOutput(encodeURIComponent(input))
} else {
setOutput(decodeURIComponent(input))
}
} catch {
setOutput(mode === 'decode' ? '解码失败,URL 格式无效' : '编码失败')
}
}, [input, mode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(output)
}, [output])
return (
<div className="h-full flex flex-col gap-4">
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'encode'} onChange={() => setMode('encode')} className="accent-blue-500" />
<span className="text-sm"> (Encode)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" checked={mode === 'decode'} onChange={() => setMode('decode')} className="accent-blue-500" />
<span className="text-sm"> (Decode)</span>
</label>
</div>
<div className="flex-1 flex flex-col">
<label className="text-sm text-gray-400 mb-1">{mode === 'encode' ? '输入文本' : '输入已编码的 URL'}</label>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none focus:border-blue-500"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={mode === 'encode' ? '输入要编码的文本...' : '输入已编码的 URL...'}
/>
</div>
<button onClick={handleProcess} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors w-fit">
{mode === 'encode' ? '编码 →' : '解码 →'}
</button>
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-gray-400"></label>
{output && <button onClick={handleCopy} className="text-xs text-blue-400 hover:text-blue-300"></button>}
</div>
<textarea
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg p-3 font-mono text-sm resize-none outline-none"
value={output}
readOnly
placeholder="结果..."
/>
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})