Files
scriptforge/frontend/src/pages/Admin.tsx
zhilv 5414c9c865 feat: 分类/变体体系 + 用户认证 + 管理后台
- 运行时分类体系:Shell/Python/JavaScript/Ruby/PHP 各含变体
- 用户注册/登录(JWT + bcrypt),首个注册用户为管理员
- 管理后台 /admin 动态管理分类和变体
- 脚本市场支持按分类筛选
- CodeMirror 语言模式根据分类名称自动切换
- 结果页展示该分类下所有变体的运行命令
- source 命令变体用于 Shell 类继承环境变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:02:20 +08:00

247 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react'
import { listCategories, getMe, logout, adminCreateCategory, adminUpdateCategory, adminDeleteCategory, adminCreateVariant, adminUpdateVariant, adminDeleteVariant } from '../lib/api'
import { RuntimeCategory, RuntimeVariant } from '../types'
export default function Admin() {
const [categories, setCategories] = useState<RuntimeCategory[]>([])
const [user, setUser] = useState<{ id: number; username: string; role: string } | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Category form
const [editingCat, setEditingCat] = useState<RuntimeCategory | null>(null)
const [catName, setCatName] = useState('')
const [catLabel, setCatLabel] = useState('')
const [catIcon, setCatIcon] = useState('')
const [catOrder, setCatOrder] = useState(0)
// Variant form
const [selectedCatId, setSelectedCatId] = useState<number>(0)
const [editingVariant, setEditingVariant] = useState<RuntimeVariant | null>(null)
const [vName, setVName] = useState('')
const [vLabel, setVLabel] = useState('')
const [vExt, setVExt] = useState('')
const [vMime, setVMime] = useState('')
const [vCmd, setVCmd] = useState('')
const [vSrcCmd, setVSrcCmd] = useState('')
const [vDefault, setVDefault] = useState(false)
const [vOrder, setVOrder] = useState(0)
useEffect(() => {
Promise.all([
getMe().catch(() => null),
listCategories().catch(() => [] as RuntimeCategory[]),
]).then(([u, cats]) => {
setUser(u)
setCategories(cats)
setLoading(false)
if (!u || u.role !== 'admin') setError('无权访问管理后台')
})
}, [])
const refresh = () => listCategories().then(setCategories)
const resetCatForm = () => {
setEditingCat(null)
setCatName('')
setCatLabel('')
setCatIcon('')
setCatOrder(0)
}
const editCat = (cat: RuntimeCategory) => {
setEditingCat(cat)
setCatName(cat.Name)
setCatLabel(cat.Label)
setCatIcon(cat.Icon)
setCatOrder(cat.SortOrder)
}
const saveCategory = async () => {
try {
if (editingCat) {
await adminUpdateCategory(editingCat.ID, { Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder })
} else {
await adminCreateCategory({ Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder })
}
resetCatForm()
await refresh()
} catch (e) {
alert('操作失败')
}
}
const deleteCategory = async (id: number) => {
if (!confirm('确定删除此分类?相关变体也会被删除。')) return
try {
await adminDeleteCategory(id)
await refresh()
} catch { alert('删除失败') }
}
const resetVForm = () => {
setEditingVariant(null)
setVName('')
setVLabel('')
setVExt('')
setVMime('')
setVCmd('')
setVSrcCmd('')
setVDefault(false)
setVOrder(0)
}
const editVariant = (v: RuntimeVariant) => {
setEditingVariant(v)
setSelectedCatId(v.CategoryID)
setVName(v.Name)
setVLabel(v.Label)
setVExt(v.Extension)
setVMime(v.MIMEType)
setVCmd(v.CommandTemplate)
setVSrcCmd(v.SourceTemplate)
setVDefault(v.IsDefault)
setVOrder(v.SortOrder)
}
const saveVariant = async () => {
try {
const payload = {
Name: vName,
Label: vLabel,
Extension: vExt,
MIMEType: vMime,
CommandTemplate: vCmd,
SourceTemplate: vSrcCmd,
IsDefault: vDefault,
SortOrder: vOrder,
}
if (editingVariant) {
await adminUpdateVariant(editingVariant.ID, payload)
} else {
await adminCreateVariant(selectedCatId, payload)
}
resetVForm()
await refresh()
} catch { alert('操作失败') }
}
const deleteVariant = async (id: number) => {
if (!confirm('确定删除此变体?')) return
try {
await adminDeleteVariant(id)
await refresh()
} catch { alert('删除失败') }
}
if (loading) return <div className="text-center py-20 text-gray-500 animate-pulse">...</div>
if (error || !user || user.role !== 'admin') {
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-4">{error || '请使用管理员账号登录'}</p>
<a href="/login" className="text-blue-400 hover:underline"></a>
</div>
)
}
const selectedCat = categories.find(c => c.ID === selectedCatId)
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-400">{user.username}</span>
<button onClick={() => { logout(); window.location.href = '/' }} className="text-gray-500 hover:text-red-400 transition-colors">退</button>
</div>
</div>
{/* Category Form */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h2 className="text-sm font-medium mb-3">{editingCat ? '编辑分类' : '新增分类'}</h2>
<div className="grid grid-cols-4 gap-3">
<input value={catName} onChange={e => setCatName(e.target.value)} placeholder="名称 (shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={catLabel} onChange={e => setCatLabel(e.target.value)} placeholder="标签 (Shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={catIcon} onChange={e => setCatIcon(e.target.value)} placeholder="图标 (🐚)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={catOrder} onChange={e => setCatOrder(Number(e.target.value))} placeholder="排序" type="number" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
</div>
<div className="flex gap-2 mt-3">
<button onClick={saveCategory} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs">{editingCat ? '更新' : '创建'}</button>
{editingCat && <button onClick={resetCatForm} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs"></button>}
</div>
</div>
{/* Categories List */}
{categories.map(cat => (
<div key={cat.ID} className="bg-gray-800/30 border border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-lg">{cat.Icon}</span>
<span className="font-medium">{cat.Label}</span>
<span className="text-xs text-gray-500 font-mono">({cat.Name})</span>
<span className="text-xs text-gray-600">: {cat.SortOrder}</span>
</div>
<div className="flex gap-2">
<button onClick={() => editCat(cat)} className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs"></button>
<button onClick={() => deleteCategory(cat.ID)} className="px-2 py-1 bg-red-700 hover:bg-red-600 rounded text-xs"></button>
</div>
</div>
{/* Variants */}
<div className="ml-4 space-y-2">
{cat.Variants?.map(v => (
<div key={v.ID} className="flex items-center justify-between bg-gray-900/50 rounded px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<span className="font-mono text-blue-400">{v.Name}</span>
<span className="text-gray-400">{v.Label}</span>
<span className="text-gray-600">.{v.Extension}</span>
{v.IsDefault && <span className="text-green-500"></span>}
</div>
<div className="flex gap-2">
<button onClick={() => { setSelectedCatId(cat.ID); editVariant(v) }} className="px-2 py-0.5 bg-gray-700 hover:bg-gray-600 rounded text-xs"></button>
<button onClick={() => deleteVariant(v.ID)} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 rounded text-xs"></button>
</div>
</div>
))}
<button onClick={() => { resetVForm(); setSelectedCatId(cat.ID) }} className="text-xs text-blue-400 hover:underline">+ </button>
</div>
</div>
))}
{/* Variant Form */}
{(selectedCatId > 0 || editingVariant) && (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h2 className="text-sm font-medium mb-3">
{editingVariant ? '编辑变体' : `${selectedCat?.Label || selectedCatId} 添加变体`}
</h2>
<div className="grid grid-cols-4 gap-3">
<input value={vName} onChange={e => setVName(e.target.value)} placeholder="名称 (bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={vLabel} onChange={e => setVLabel(e.target.value)} placeholder="标签 (Bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={vExt} onChange={e => setVExt(e.target.value)} placeholder="扩展名 (.sh)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={vMime} onChange={e => setVMime(e.target.value)} placeholder="MIME" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<input value={vCmd} onChange={e => setVCmd(e.target.value)} placeholder="命令模板 (curl {url} | bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
<input value={vSrcCmd} onChange={e => setVSrcCmd(e.target.value)} placeholder="source模板 (可选)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
</div>
<div className="flex items-center gap-6 mt-3">
<div className="flex items-center gap-2">
<input type="number" value={vOrder} onChange={e => setVOrder(Number(e.target.value))} placeholder="排序" className="w-20 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" />
</div>
<label className="flex items-center gap-2 text-xs text-gray-400">
<input type="checkbox" checked={vDefault} onChange={e => setVDefault(e.target.checked)} className="w-3 h-3" />
</label>
</div>
<div className="flex gap-2 mt-3">
<button onClick={saveVariant} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-xs">{editingVariant ? '更新' : '创建'}</button>
<button onClick={resetVForm} className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs"></button>
</div>
</div>
)}
</div>
)
}