码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/UploadPlugin.tsx
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() : ''
}