码桶
发现社区成员的开源项目
UploadPlugin.tsx7.1 KB
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Loader2, Upload, ArrowLeft, FileCode, Info, Bot } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function UploadPlugin() {
const { me, bots } = useAuth()
const navigate = useNavigate()
const fileRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [botId, setBotId] = useState(bots[0]?.id || 0)
const [submitting, setSubmitting] = useState(false)
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) {
if (pathExt(f.name) !== '.js') { toast.error('仅支持 .js 插件文件'); return }
if (f.size > 500 * 1024) { toast.error('文件不能超过 500KB'); return }
setFile(f)
}
}
async function submit() {
if (!name.trim()) { toast.error('请输入插件名称'); return }
if (!desc.trim()) { toast.error('请输入插件描述'); return }
if (!file) { toast.error('请上传插件文件'); return }
if (!botId) { toast.error('请选择安装到哪个机器人'); return }
setSubmitting(true)
try {
const fd = new FormData()
fd.append('plugin_file', file)
fd.append('name', name.trim())
fd.append('description', desc.trim())
if (botId) fd.append('bot_id', String(botId))
const d = await api.upload('/api/market/upload', fd)
if (!d.ok) throw new Error(d.msg)
toast.success(d.msg)
navigate('/market', { state: { tab: 'self' } })
} catch (e: any) {
toast.error(e.message)
} finally {
setSubmitting(false)
}
}
if (!me) {
return (
<div className="flex min-h-[60vh] items-center justify-center text-sm text-muted-foreground">
请先登录
</div>
)
}
return (
<div className="max-w-2xl p-3 md:p-6">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/market')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<Upload className="h-5 w-5" />
<h1 className="text-lg font-semibold">上传插件</h1>
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-base">插件信息</CardTitle>
<p className="text-xs text-muted-foreground">
上传后立即安装到你的机器人中。你也可以选择将插件挂到市场出售。
</p>
</CardHeader>
<CardContent className="space-y-5">
{/* 名称 */}
<div className="space-y-1.5">
<Label className="text-sm">插件名称 <span className="text-destructive">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)}
placeholder="给插件起个名字" maxLength={64} />
</div>
{/* 描述 */}
<div className="space-y-1.5">
<Label className="text-sm">描述 <span className="text-destructive">*</span></Label>
<Textarea value={desc} onChange={e => setDesc(e.target.value)}
placeholder="介绍插件功能和使用方法" className="min-h-[80px]" maxLength={500} />
<p className="text-[11px] text-muted-foreground">{desc.length}/500</p>
</div>
{/* 安装到哪个机器人 */}
{bots.length > 0 && (
<div className="space-y-1.5">
<Label className="text-sm">安装到</Label>
<div className="flex flex-wrap gap-1.5">
{bots.map(b => (
<Badge key={b.id}
variant={botId === b.id ? 'default' : 'outline'}
className="cursor-pointer gap-1 text-xs"
onClick={() => setBotId(b.id)}
>
<Bot className="h-3 w-3" />
{b.name || ('机器人 #' + b.id)}
</Badge>
))}
</div>
</div>
)}
{/* 文件上传 */}
<div className="space-y-1.5">
<Label className="text-sm">插件文件 (.js) <span className="text-destructive">*</span></Label>
<div
onClick={() => fileRef.current?.click()}
className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/30 p-8 transition-colors hover:border-primary/50 hover:bg-muted/30"
>
{file ? (
<>
<FileCode className="h-8 w-8 text-primary" />
<span className="text-sm font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">{(file.size / 1024).toFixed(1)} KB</span>
<span className="text-xs text-muted-foreground">点击重新选择</span>
</>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">点击上传 .js 插件文件</span>
<span className="text-xs text-muted-foreground">最大 500KB</span>
</>
)}
</div>
<input ref={fileRef} type="file" accept=".js" onChange={onFileChange} className="hidden" />
</div>
<div className="rounded-lg bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground">
<div className="flex items-start gap-1.5">
<Info className="mt-0.5 h-3 w-3 shrink-0" />
<div>
<p className="font-medium text-foreground/80">使用说明</p>
<p>上传后立即安装到你的机器人,仅你自己可用。</p>
<p className="mt-1">文件名以 <code className="rounded bg-muted px-1 text-[10px]">index.js</code> 结尾,导出 <code className="rounded bg-muted px-1 text-[10px]">meta</code> 和 <code className="rounded bg-muted px-1 text-[10px]">onMessage(msg, ctx)</code>。</p>
</div>
</div>
</div>
<Button className="w-full" size="lg" disabled={submitting} onClick={submit}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
上传并安装
</Button>
</CardContent>
</Card>
</div>
)
}
function pathExt(name: string): string {
const i = name.lastIndexOf('.')
return i > 0 ? name.slice(i).toLowerCase() : ''
}