初始提交: ScriptForge 脚本快速转运行链接服务
Some checks failed
Release / build-and-release (push) Failing after 1m31s

- Go 后端 (Gin + GORM + SQLite) 提供 API 和纯文本脚本服务
- Vite + React + TypeScript + Tailwind 前端
- 单二进制部署 (Go embed 前端静态文件)
- Gitea Actions CI/CD: 打标签自动构建多平台 Release
- 支持 bash/zsh/sh/fish/python3/node/ruby/php 8种运行环境

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:48:19 +08:00
commit 10a200b96c
37 changed files with 4400 additions and 0 deletions

13
frontend/index.html Normal file
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>ScriptForge - 脚本快速转运行链接</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
</head>
<body class="bg-gray-950 text-gray-100 min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2722
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "scriptforge-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { Routes, Route, Link } from 'react-router-dom'
import Home from './pages/Home'
import ScriptDetail from './pages/ScriptDetail'
import DeleteScript from './pages/DeleteScript'
export default function App() {
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-gray-800">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="text-xl font-bold tracking-tight hover:text-blue-400 transition-colors">
<span className="text-blue-500"></span> ScriptForge
</Link>
<span className="text-sm text-gray-500"></span>
</div>
</header>
<main className="flex-1 max-w-4xl mx-auto px-4 py-8 w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/s/:id" element={<ScriptDetail />} />
<Route path="/s/:id/delete" element={<DeleteScript />} />
</Routes>
</main>
<footer className="border-t border-gray-800 text-center text-xs text-gray-600 py-4">
ScriptForge
</footer>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
interface Props {
command: string
}
export default function CommandCard({ command }: Props) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(command).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="bg-gray-900 border border-blue-500/30 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-blue-500/10 border-b border-blue-500/20">
<span className="text-xs text-blue-400 font-medium"></span>
</div>
<div className="p-4 flex items-center gap-3">
<code className="flex-1 text-sm font-mono text-green-400 break-all select-all">
{command}
</code>
<button
onClick={handleCopy}
className="shrink-0 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs font-medium transition-colors"
>
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { CreateScriptResponse } from '../types'
import CommandCard from './CommandCard'
interface Props {
result: CreateScriptResponse
onReset: () => void
}
export default function ResultCard({ result, onReset }: Props) {
const detailUrl = `${window.location.origin}/s/${result.id}`
return (
<div className="space-y-6">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<h2 className="text-xl font-bold"></h2>
</div>
<CommandCard command={result.command} />
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400"> ID</span>
<span className="font-mono text-blue-400">{result.id}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400"></span>
<span>{result.runtime}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400"></span>
<span>{new Date(result.expires_at).toLocaleString('zh-CN')}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400"></span>
<a
href={detailUrl}
target="_blank"
rel="noreferrer"
className="text-blue-400 hover:underline font-mono text-xs"
>
{detailUrl}
</a>
</div>
<div className="pt-2 border-t border-gray-700">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="font-mono text-xs bg-gray-900 px-3 py-2 rounded break-all select-all">
{result.admin_token}
</div>
</div>
</div>
<div className="flex gap-3 justify-center">
<button
onClick={onReset}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm transition-colors"
>
</button>
<a
href={detailUrl}
target="_blank"
rel="noreferrer"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors"
>
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react'
import { RUNTIME_OPTIONS, EXPIRES_OPTIONS, RuntimeOption, ExpiresIn } from '../types'
interface Props {
onSubmit: (content: string, runtime: RuntimeOption, expiresIn: ExpiresIn) => void
loading: boolean
}
export default function ScriptForm({ onSubmit, loading }: Props) {
const [content, setContent] = useState('')
const [runtime, setRuntime] = useState<RuntimeOption>('bash')
const [expiresIn, setExpiresIn] = useState<ExpiresIn>('24h')
const canSubmit = content.trim().length > 0 && content.length <= 16384 && !loading
return (
<div className="space-y-4">
<div className="flex gap-4 flex-wrap">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<select
value={runtime}
onChange={(e) => setRuntime(e.target.value as RuntimeOption)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
>
{RUNTIME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<select
value={expiresIn}
onChange={(e) => setExpiresIn(e.target.value as ExpiresIn)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500"
>
{EXPIRES_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="在此粘贴你的脚本..."
rows={12}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-y"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{content.length.toLocaleString()} / 16,384
{content.length > 16384 && (
<span className="text-red-400 ml-1"></span>
)}
</span>
<button
onClick={() => onSubmit(content, runtime, expiresIn)}
disabled={!canSubmit}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
>
{loading ? '生成中...' : '生成运行链接'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
interface Props {
content: string
runtime: string
}
export default function ScriptViewer({ content, runtime }: Props) {
return (
<div className="bg-gray-900 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/50 border-b border-gray-700">
<span className="text-xs text-gray-400 font-mono">script.{runtime === 'node' ? 'js' : runtime === 'python3' ? 'py' : runtime}</span>
<button
onClick={() => navigator.clipboard.writeText(content)}
className="text-xs text-gray-500 hover:text-blue-400 transition-colors"
>
</button>
</div>
<pre className="p-4 text-sm font-mono overflow-x-auto leading-relaxed text-gray-200">
{content}
</pre>
</div>
)
}

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

44
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,44 @@
import { CreateScriptResponse, ScriptDetail, RuntimeOption, ExpiresIn } from '../types'
const BASE = ''
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(BASE + url, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error || `HTTP ${res.status}`)
}
return res.json()
}
export async function createScript(params: {
content: string
runtime: RuntimeOption
expires_in: ExpiresIn
}): Promise<CreateScriptResponse> {
return request('/api/scripts', {
method: 'POST',
body: JSON.stringify(params),
})
}
export async function getScript(id: string): Promise<ScriptDetail> {
return request(`/api/scripts/${id}`)
}
export async function deleteScript(id: string, token: string): Promise<void> {
await fetch(`${BASE}/api/scripts/${id}?token=${encodeURIComponent(token)}`, {
method: 'DELETE',
}).then((res) => {
if (!res.ok && res.status !== 204) {
throw new Error('delete failed')
}
})
}
export function getCommandUrl(id: string): string {
return `${window.location.origin}/raw/${id}`
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,72 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { deleteScript } from '../lib/api'
export default function DeleteScript() {
const { id } = useParams<{ id: string }>()
const [token, setToken] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deleted, setDeleted] = useState(false)
const handleDelete = async () => {
if (!id || !token.trim()) return
setLoading(true)
setError(null)
try {
await deleteScript(id, token.trim())
setDeleted(true)
} catch (e) {
setError(e instanceof Error ? e.message : '删除失败')
} finally {
setLoading(false)
}
}
if (deleted) {
return (
<div className="text-center py-20">
<div className="text-6xl mb-4">🗑</div>
<h2 className="text-xl font-bold mb-2"></h2>
<Link to="/" className="text-blue-400 hover:underline">
</Link>
</div>
)
}
return (
<div className="max-w-md mx-auto py-12">
<h1 className="text-xl font-bold mb-4"></h1>
<p className="text-gray-400 text-sm mb-6">
admin_token <code className="text-blue-400">{id}</code>
</p>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="粘贴 admin_token"
className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500 mb-4"
/>
{error && <p className="text-red-400 text-sm mb-4">{error}</p>}
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={loading || !token.trim()}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
>
{loading ? '删除中...' : '确认删除'}
</button>
<Link
to={`/s/${id}`}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm transition-colors"
>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react'
import ScriptForm from '../components/ScriptForm'
import ResultCard from '../components/ResultCard'
import { createScript } from '../lib/api'
import { CreateScriptResponse, RuntimeOption, ExpiresIn } from '../types'
export default function Home() {
const [result, setResult] = useState<CreateScriptResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = useCallback(async (content: string, runtime: RuntimeOption, expiresIn: ExpiresIn) => {
setLoading(true)
setError(null)
setResult(null)
try {
const res = await createScript({ content, runtime, expires_in: expiresIn })
setResult(res)
} catch (e) {
setError(e instanceof Error ? e.message : '创建失败')
} finally {
setLoading(false)
}
}, [])
const handleReset = useCallback(() => {
setResult(null)
setError(null)
}, [])
return (
<div>
{!result ? (
<>
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-2"></h1>
<p className="text-gray-400">
<code className="text-blue-400">curl | bash</code>
</p>
</div>
<ScriptForm onSubmit={handleSubmit} loading={loading} />
{error && (
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
</>
) : (
<ResultCard result={result} onReset={handleReset} />
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import ScriptViewer from '../components/ScriptViewer'
import CommandCard from '../components/CommandCard'
import { getScript } from '../lib/api'
import { ScriptDetail as ScriptDetailType } from '../types'
export default function ScriptDetail() {
const { id } = useParams<{ id: string }>()
const [script, setScript] = useState<ScriptDetailType | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
getScript(id)
.then(setScript)
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false))
}, [id])
if (loading) {
return (
<div className="flex justify-center py-20">
<div className="animate-pulse text-gray-500">...</div>
</div>
)
}
if (error || !script) {
return (
<div className="text-center py-20">
<div className="text-6xl mb-4">😕</div>
<h2 className="text-xl font-bold mb-2"></h2>
<p className="text-gray-400 mb-6">{error}</p>
<Link to="/" className="text-blue-400 hover:underline">
</Link>
</div>
)
}
const command = `curl ${window.location.origin}/raw/${script.id} | ${script.runtime}`
return (
<div>
<div className="mb-6">
<Link to="/" className="text-sm text-gray-500 hover:text-blue-400 transition-colors">
&larr;
</Link>
</div>
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold font-mono">{script.id}</h1>
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-400 text-xs rounded-full border border-blue-600/30">
{script.runtime}
</span>
<span className="text-xs text-gray-500">
{new Date(script.expires_at).toLocaleString('zh-CN')}
</span>
</div>
<ScriptViewer content={script.content} runtime={script.runtime} />
<div className="mt-8">
<CommandCard command={command} />
</div>
<div className="mt-8 text-center">
<Link
to={`/s/${script.id}/delete`}
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
>
</Link>
</div>
</div>
)
}

39
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface CreateScriptResponse {
id: string
admin_token: string
url: string
command: string
runtime: string
expires_at: string
}
export interface ScriptDetail {
id: string
runtime: string
content: string
content_length: number
created_at: string
expires_at: string
expired: boolean
}
export type RuntimeOption = 'bash' | 'zsh' | 'sh' | 'fish' | 'python3' | 'node' | 'ruby' | 'php'
export type ExpiresIn = '1h' | '24h' | '7d' | '30d'
export const RUNTIME_OPTIONS: { value: RuntimeOption; label: string }[] = [
{ value: 'bash', label: 'Bash' },
{ value: 'zsh', label: 'Zsh' },
{ value: 'sh', label: 'Sh' },
{ value: 'fish', label: 'Fish' },
{ value: 'python3', label: 'Python 3' },
{ value: 'node', label: 'Node.js' },
{ value: 'ruby', label: 'Ruby' },
{ value: 'php', label: 'PHP' },
]
export const EXPIRES_OPTIONS: { value: ExpiresIn; label: string }[] = [
{ value: '1h', label: '1 小时' },
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
]

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

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

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

13
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': { target: 'http://localhost:8080' },
'/raw': { target: 'http://localhost:8080' },
},
},
})