码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/components/PluginConfigPanel.tsx
PluginConfigPanel.tsx8 KB
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { PushConfig } from '@/components/PushConfig'
import { RssConfig } from '@/components/RssConfig'

export interface PluginSettingsField {
  key: string
  label?: string
  type?: string
  placeholder?: string
  help?: string
  default?: string
  options?: { label?: string; value: string }[]
}

export interface PluginForSettings {
  id: string
  name: string
  settingsSchema?: PluginSettingsField[] | null
  /** 可能是对象(通用 key-value)或字符串标识符(专用 UI:'push' / 'rss_feeds') */
  customConfig?: Record<string, any> | string | null
  configurable?: boolean
}

/** 字符串型 customConfig 对应的专用组件 */
function renderCustomUi(kind: string, botId: number) {
  if (kind === 'push') return <PushConfig botId={botId} />
  if (kind === 'rss_feeds') return <RssConfig botId={botId} />
  return null
}

/** 单个插件的自定义设置编辑器,可复用于控制台与市场页 */
export function PluginSettingsEditor({ plugin, botId }: { plugin: PluginForSettings; botId: number }) {
  const [values, setValues] = useState<Record<string, any>>({})
  const [loading, setLoading] = useState(true)
  const [saving, setSaving] = useState(false)

  const fields: PluginSettingsField[] = Array.isArray(plugin.settingsSchema) ? plugin.settingsSchema : []
  const customConfigObj = plugin.customConfig && typeof plugin.customConfig === 'object' ? plugin.customConfig : null
  const customConfigKind = typeof plugin.customConfig === 'string' && plugin.customConfig ? plugin.customConfig : null
  const customUi = customConfigKind ? renderCustomUi(customConfigKind, botId) : null
  const isConfigurable = fields.length > 0 || !!customConfigObj || !!customUi

  useEffect(() => {
    let active = true
    setLoading(true)
    api.post('/api/market/plugin_settings', { plugin_id: plugin.id, bot_id: botId })
      .then(r => {
        if (!active) return
        if (r.ok) setValues(r.settings || {})
      })
      .catch(e => toast.error('读取配置失败:' + e.message))
      .finally(() => { if (active) setLoading(false) })
    return () => { active = false }
  }, [plugin.id, botId])

  if (loading) {
    return <div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />读取配置中…</div>
  }
  // 字符串型 customConfig:优先渲染专用 UI
  if (customUi) {
    return customUi
  }
  if (!isConfigurable) {
    return <p className="text-xs text-muted-foreground">该插件没有可配置项。</p>
  }

  const setField = (k: string, v: any) => setValues(prev => ({ ...prev, [k]: v }))

  const renderField = (f: PluginSettingsField) => {
    const type = (f.type || 'text').toLowerCase()
    const label = f.label || f.key
    const val = values[f.key]
    if (type === 'switch') {
      const on = val === '1' || val === 1 || val === true
      return (
        <div key={f.key} className="flex items-center justify-between gap-3 rounded-md border p-2.5">
          <div className="min-w-0">
            <Label className="text-xs">{label}</Label>
            {f.help && <p className="text-[11px] text-muted-foreground mt-0.5">{f.help}</p>}
          </div>
          <Switch checked={!!on} onCheckedChange={c => setField(f.key, c ? '1' : '0')} />
        </div>
      )
    }
    if (type === 'textarea') {
      return (
        <div key={f.key} className="space-y-1.5">
          <Label className="text-xs">{label}</Label>
          <Textarea value={val ?? ''} placeholder={f.placeholder || ''} onChange={e => setField(f.key, e.target.value)} />
          {f.help && <p className="text-[11px] text-muted-foreground">{f.help}</p>}
        </div>
      )
    }
    if (type === 'select') {
      return (
        <div key={f.key} className="space-y-1.5">
          <Label className="text-xs">{label}</Label>
          <Select value={val ?? ''} onValueChange={v => setField(f.key, v)}>
            <SelectTrigger><SelectValue placeholder={label} /></SelectTrigger>
            <SelectContent>
              {(f.options || []).map(o => <SelectItem key={o.value} value={o.value}>{o.label || o.value}</SelectItem>)}
            </SelectContent>
          </Select>
          {f.help && <p className="text-[11px] text-muted-foreground">{f.help}</p>}
        </div>
      )
    }
    return (
      <div key={f.key} className="space-y-1.5">
        <Label className="text-xs">{label}</Label>
        <Input
          type={type === 'number' ? 'number' : type === 'password' ? 'password' : 'text'}
          value={val ?? ''}
          placeholder={f.placeholder || ''}
          onChange={e => setField(f.key, e.target.value)}
        />
        {f.help && <p className="text-[11px] text-muted-foreground">{f.help}</p>}
      </div>
    )
  }

  return (
    <div className="space-y-3">
      {fields.map(renderField)}
      {customConfigObj && (
        <div className="space-y-2 rounded-md border p-2">
          <p className="text-xs font-medium">高级自定义配置</p>
          {Object.entries(customConfigObj).map(([k, v]) => (
            <div key={k} className="space-y-1">
              <Label className="text-xs">{k}</Label>
              <Input value={values[k] ?? String(v)} onChange={e => setField(k, e.target.value)} />
            </div>
          ))}
        </div>
      )}
      <Button size="sm" disabled={saving} onClick={async () => {
        setSaving(true)
        try {
          const r = await api.post('/api/market/plugin_settings_save', { plugin_id: plugin.id, bot_id: botId, data: values })
          if (r.ok) toast.success('已保存配置')
          else toast.error(r.msg || '保存失败')
        } catch (e: any) { toast.error('保存失败:' + e.message) } finally { setSaving(false) }
      }}>
        {saving && <Loader2 className="h-4 w-4 animate-spin" />} 保存配置
      </Button>
    </div>
  )
}

/** 控制台侧栏:选择已安装可配置插件并进行设置 */
export function PluginConfigPanel({ botId }: { botId: number }) {
  const [plugins, setPlugins] = useState<PluginForSettings[]>([])
  const [selId, setSelId] = useState('')
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    let active = true
    api.post('/api/market/list', { bot_id: botId, pageSize: 50 })
      .then(r => {
        if (!active) return
        if (r.ok) {
          const ps = (r.plugins || []).filter((p: any) => p.installed && p.configurable)
          setPlugins(ps)
          if (ps[0]) setSelId(ps[0].id)
        }
      })
      .catch(e => toast.error(e.message))
      .finally(() => { if (active) setLoading(false) })
    return () => { active = false }
  }, [])

  if (loading) return <div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />加载中…</div>
  if (!plugins.length) return <p className="text-xs text-muted-foreground">当前没有已安装的可配置插件。</p>

  const sel = plugins.find(p => p.id === selId)
  return (
    <div className="space-y-3">
      <Select value={selId} onValueChange={setSelId}>
        <SelectTrigger><SelectValue placeholder="选择插件" /></SelectTrigger>
        <SelectContent>
          {plugins.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
        </SelectContent>
      </Select>
      {sel && <PluginSettingsEditor plugin={sel} botId={botId} />}
    </div>
  )
}