initial release
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
*.local
|
||||
.DS_Store
|
||||
+24
@@ -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?
|
||||
@@ -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
@@ -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;"]
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>';
|
||||
}
|
||||
}
|
||||
Generated
+3054
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 实体(如 <div>)...'}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
Reference in New Issue
Block a user