import { useState, useEffect, useCallback } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CodeEditor } from '@/components'
import { parse as parseToml } from 'smol-toml'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Settings,
Package,
AlertCircle,
CheckCircle2,
RefreshCw,
ChevronRight,
ChevronDown,
Save,
RotateCcw,
Power,
Loader2,
Search,
ArrowLeft,
Info,
Eye,
EyeOff,
RotateCw,
Code2,
Layout,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay'
import {
getInstalledPlugins,
getPluginConfigSchema,
getPluginConfig,
getPluginConfigRaw,
updatePluginConfig,
updatePluginConfigRaw,
resetPluginConfig,
togglePlugin,
type InstalledPlugin,
type PluginConfigSchema,
type ConfigFieldSchema,
type ConfigSectionSchema,
} from '@/lib/plugin-api'
// 字段渲染组件
interface FieldRendererProps {
field: ConfigFieldSchema
value: unknown
onChange: (value: unknown) => void
sectionName: string
}
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
const [showPassword, setShowPassword] = useState(false)
// 根据 ui_type 渲染不同的控件
switch (field.ui_type) {
case 'switch':
return (
{field.hint && (
{field.hint}
)}
)
case 'number':
return (
onChange(parseFloat(e.target.value) || 0)}
min={field.min}
max={field.max}
step={field.step ?? 1}
placeholder={field.placeholder}
disabled={field.disabled}
/>
{field.hint && (
{field.hint}
)}
)
case 'slider':
return (
{value as number ?? field.default}
onChange(v[0])}
min={field.min ?? 0}
max={field.max ?? 100}
step={field.step ?? 1}
disabled={field.disabled}
/>
{field.hint && (
{field.hint}
)}
)
case 'select':
return (
{field.hint && (
{field.hint}
)}
)
case 'textarea':
return (
)
case 'password':
return (
onChange(e.target.value)}
placeholder={field.placeholder}
disabled={field.disabled}
className="pr-10"
/>
{field.hint && (
{field.hint}
)}
)
case 'list':
return (
onChange(newValue)}
itemType={field.item_type ?? 'string'}
itemFields={field.item_fields}
minItems={field.min_items}
maxItems={field.max_items}
disabled={field.disabled}
placeholder={field.placeholder}
/>
{field.hint && (
{field.hint}
)}
)
case 'text':
default:
return (
onChange(e.target.value)}
placeholder={field.placeholder}
maxLength={field.max_length}
disabled={field.disabled}
/>
{field.hint && (
{field.hint}
)}
)
}
}
// Section 渲染组件
interface SectionRendererProps {
section: ConfigSectionSchema
config: Record
onChange: (sectionName: string, fieldName: string, value: unknown) => void
}
function SectionRenderer({ section, config, onChange }: SectionRendererProps) {
const [isOpen, setIsOpen] = useState(!section.collapsed)
// 按 order 排序字段
const sortedFields = Object.entries(section.fields)
.filter(([, field]) => !field.hidden)
.sort(([, a], [, b]) => a.order - b.order)
return (
{isOpen ? (
) : (
)}
{section.title}
{sortedFields.length} 项
{section.description && (
{section.description}
)}
{sortedFields.map(([fieldName, field]) => (
)?.[fieldName]}
onChange={(value) => onChange(section.name, fieldName, value)}
sectionName={section.name}
/>
))}
)
}
// 插件配置编辑器
interface PluginConfigEditorProps {
plugin: InstalledPlugin
onBack: () => void
}
function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
const [schema, setSchema] = useState(null)
const [config, setConfig] = useState>({})
const [originalConfig, setOriginalConfig] = useState>({})
const [sourceCode, setSourceCode] = useState('')
const [originalSourceCode, setOriginalSourceCode] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [hasTomlError, setHasTomlError] = useState(false)
const [resetDialogOpen, setResetDialogOpen] = useState(false)
// 加载配置
const loadConfig = useCallback(async () => {
setLoading(true)
try {
const [schemaResult, configResult, rawResult] = await Promise.all([
getPluginConfigSchema(plugin.id),
getPluginConfig(plugin.id),
getPluginConfigRaw(plugin.id)
])
if (!schemaResult.success) {
toast({
title: '加载配置架构失败',
description: schemaResult.error,
variant: 'destructive'
})
return
}
if (!configResult.success) {
toast({
title: '加载配置数据失败',
description: configResult.error,
variant: 'destructive'
})
return
}
if (!rawResult.success) {
toast({
title: '加载原始配置失败',
description: rawResult.error,
variant: 'destructive'
})
return
}
setSchema(schemaResult.data)
setConfig(configResult.data)
setOriginalConfig(JSON.parse(JSON.stringify(configResult.data)))
setSourceCode(rawResult.data)
setOriginalSourceCode(rawResult.data)
} catch (error) {
toast({
title: '加载配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setLoading(false)
}
}, [plugin.id, toast])
useEffect(() => {
loadConfig()
}, [loadConfig])
// 检测配置变化
useEffect(() => {
if (editMode === 'visual') {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
} else {
setHasChanges(sourceCode !== originalSourceCode)
}
}, [config, originalConfig, sourceCode, originalSourceCode, editMode])
// 处理字段变化
const handleFieldChange = (sectionName: string, fieldName: string, value: unknown) => {
setConfig(prev => ({
...prev,
[sectionName]: {
...(prev[sectionName] as Record || {}),
[fieldName]: value
}
}))
}
// 保存配置
const handleSave = async () => {
setSaving(true)
try {
if (editMode === 'source') {
// 源代码模式:先验证 TOML 格式
try {
parseToml(sourceCode)
} catch (error) {
setHasTomlError(true)
toast({
title: 'TOML 格式错误',
description: error instanceof Error ? error.message : '无法解析 TOML 配置,请检查语法',
variant: 'destructive'
})
setSaving(false)
return
}
// 格式正确,保存原始配置
await updatePluginConfigRaw(plugin.id, sourceCode)
setOriginalSourceCode(sourceCode)
setHasTomlError(false)
} else {
// 可视化模式
await updatePluginConfig(plugin.id, config)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
}
toast({
title: '配置已保存',
description: '更改将在插件重新加载后生效'
})
} catch (error) {
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setSaving(false)
}
}
// 重置配置
const handleReset = async () => {
try {
const resetResult = await resetPluginConfig(plugin.id)
if (!resetResult.success) {
toast({
title: '重置失败',
description: resetResult.error,
variant: 'destructive'
})
return
}
toast({
title: '配置已重置',
description: '下次加载插件时将使用默认配置'
})
setResetDialogOpen(false)
loadConfig()
} catch (error) {
toast({
title: '重置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
}
}
// 切换启用状态
const handleToggle = async () => {
try {
const toggleResult = await togglePlugin(plugin.id)
if (!toggleResult.success) {
toast({
title: '切换失败',
description: toggleResult.error,
variant: 'destructive'
})
return
}
toast({
title: toggleResult.data.message,
description: toggleResult.data.note
})
loadConfig()
} catch (error) {
toast({
title: '切换状态失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
}
}
if (loading) {
return (
)
}
if (!schema) {
return (
)
}
// 按 order 排序 sections
const sortedSections = Object.values(schema.sections)
.sort((a, b) => a.order - b.order)
// 获取当前启用状态
const isEnabled = (config.plugin as Record)?.enabled !== false
return (
{/* 头部 */}
{schema.plugin_info.name || plugin.manifest.name}
{isEnabled ? '已启用' : '已禁用'}
v{schema.plugin_info.version || plugin.manifest.version}
{/* 未保存提示 */}
{hasChanges && (
)}
{/* 源代码模式 */}
{editMode === 'source' && (
源代码模式(高级功能):直接编辑 TOML 配置文件。保存时会验证格式,只有格式正确才能保存。
{hasTomlError && (
⚠️ 上次保存失败,请检查 TOML 格式
)}
{
setSourceCode(value)
if (hasTomlError) {
setHasTomlError(false)
}
}}
language="toml"
height="calc(100vh - 350px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
)}
{/* 可视化模式 */}
{editMode === 'visual' && (
<>
{/* 插件未加载提示 */}
提示:如果插件当前未加载或未启用,WebUI 适配器的高级插件可视化编辑功能可能会不可用。
请确保插件已启用并成功加载后,再进行配置编辑。
{/* 配置区域 */}
{schema.layout.type === 'tabs' && schema.layout.tabs.length > 0 ? (
// 标签页布局
{schema.layout.tabs.map(tab => (
{tab.title}
{tab.badge && (
{tab.badge}
)}
))}
{schema.layout.tabs.map(tab => (
{tab.sections.map(sectionName => {
const section = schema.sections[sectionName]
if (!section) return null
return (
)
})}
))}
) : (
// 自动布局
{sortedSections.map(section => (
))}
)}
>
)}
{/* 重置确认对话框 */}
)
}
// 主页面组件 - 包装 RestartProvider
export function PluginConfigPage() {
return (
)
}
// 内部组件:实际内容
function PluginConfigPageContent() {
const { toast } = useToast()
const [plugins, setPlugins] = useState([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlugin, setSelectedPlugin] = useState(null)
// 加载插件列表
const loadPlugins = async () => {
setLoading(true)
try {
const installedResult = await getInstalledPlugins()
if (!installedResult.success) {
toast({
title: '加载插件列表失败',
description: installedResult.error,
variant: 'destructive'
})
return
}
setPlugins(installedResult.data)
} catch (error) {
toast({
title: '加载插件列表失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPlugins()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 过滤插件
const filteredPlugins = plugins.filter(plugin => {
const query = searchQuery.toLowerCase()
return (
plugin.id.toLowerCase().includes(query) ||
plugin.manifest.name.toLowerCase().includes(query) ||
plugin.manifest.description?.toLowerCase().includes(query)
)
})
// 去重:如果有重复的 plugin.id,只保留第一个
const uniqueFilteredPlugins = filteredPlugins.filter((plugin, index, self) =>
index === self.findIndex((p) => p.id === plugin.id)
)
// 统计数据
const enabledCount = plugins.length // 暂时假设都启用
const disabledCount = 0
// 如果选中了插件,显示配置编辑器
if (selectedPlugin) {
return (
<>
setSelectedPlugin(null)}
/>
>
)
}
return (
{/* 标题 */}
{/* 统计卡片 */}
已安装插件
{plugins.length}
{loading ? '正在加载...' : '个插件'}
已启用
{enabledCount}
运行中的插件
已禁用
{disabledCount}
未激活的插件
{/* 搜索框 */}
setSearchQuery(e.target.value)}
className="pl-9"
/>
{/* 插件列表 */}
已安装的插件
点击插件查看和编辑配置
{loading ? (
) : uniqueFilteredPlugins.length === 0 ? (
{searchQuery ? '没有找到匹配的插件' : '暂无已安装的插件'}
{searchQuery ? '尝试其他搜索关键词' : '前往插件市场安装插件'}
) : (
{uniqueFilteredPlugins.map(plugin => (
setSelectedPlugin(plugin)}
>
{plugin.manifest.name}
v{plugin.manifest.version}
{plugin.manifest.description || '暂无描述'}
))}
)}
)
}