Files
mai-bot/dashboard/src/routes/config/adapter.tsx

1365 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, useCallback } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Info, Upload, Download, FileText, Trash2, FolderOpen, Save, RefreshCw, Package, ChevronDown } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useToast } from '@/hooks/use-toast'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
getSavedConfigPath,
saveConfigPath,
loadConfigFromPath,
saveConfigToPath,
} from '@/lib/adapter-config-api'
import type { AdapterConfig, PresetKey } from './adapter/types'
import { DEFAULT_CONFIG, PRESETS } from './adapter/types'
import { parseTOML, generateTOML, validatePath } from './adapter/utils'
export function AdapterConfigPage() {
// 工作模式:'upload' = 上传文件模式, 'path' = 指定路径模式, 'preset' = 预设模式
const [mode, setMode] = useState<'upload' | 'path' | 'preset'>('upload')
const [config, setConfig] = useState<AdapterConfig | null>(null)
const [fileName, setFileName] = useState<string>('')
const [configPath, setConfigPath] = useState<string>('')
const [selectedPreset, setSelectedPreset] = useState<PresetKey>('oneclick')
const [pathError, setPathError] = useState<string>('')
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [showModeSwitchDialog, setShowModeSwitchDialog] = useState(false)
const [showClearPathDialog, setShowClearPathDialog] = useState(false)
const [pendingMode, setPendingMode] = useState<'upload' | 'path' | 'preset' | null>(null)
const [isModeConfigOpen, setIsModeConfigOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
const saveTimeoutRef = useRef<number | null>(null)
// 处理路径输入变化
const handlePathChange = (value: string) => {
setConfigPath(value)
// 实时验证
if (value.trim()) {
const validation = validatePath(value)
setPathError(validation.error)
} else {
setPathError('')
}
}
// 从预设加载配置
const handleLoadFromPreset = useCallback(async (presetKey: PresetKey) => {
const preset = PRESETS[presetKey]
setIsLoading(true)
try {
const content = await loadConfigFromPath(preset.path)
const parsedConfig = parseTOML(content)
setConfig(parsedConfig)
setSelectedPreset(presetKey)
setConfigPath(preset.path)
// 保存路径偏好
await saveConfigPath(preset.path)
toast({
title: '加载成功',
description: `已从${preset.name}预设加载配置`,
})
} catch (error) {
console.error('加载预设配置失败:', error)
toast({
title: '加载失败',
description: error instanceof Error ? error.message : '无法读取预设配置文件',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast])
// 从指定路径加载配置
const handleLoadFromPath = useCallback(async (path: string) => {
// 验证路径
const validation = validatePath(path)
if (!validation.valid) {
setPathError(validation.error)
toast({
title: '路径无效',
description: validation.error,
variant: 'destructive',
})
return
}
setPathError('')
setIsLoading(true)
try {
const content = await loadConfigFromPath(path)
const parsedConfig = parseTOML(content)
setConfig(parsedConfig)
setConfigPath(path)
// 保存路径偏好
await saveConfigPath(path)
toast({
title: '加载成功',
description: `已从配置文件加载`,
})
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: error instanceof Error ? error.message : '无法读取配置文件',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast])
// 组件挂载时加载保存的路径
useEffect(() => {
const loadSavedPath = async () => {
try {
const savedPath = await getSavedConfigPath()
if (savedPath && savedPath.path) {
setConfigPath(savedPath.path)
// 检查是否是预设路径
const presetEntry = Object.entries(PRESETS).find(([, preset]) => preset.path === savedPath.path)
if (presetEntry) {
setMode('preset')
setSelectedPreset(presetEntry[0] as PresetKey)
await handleLoadFromPreset(presetEntry[0] as PresetKey)
} else {
setMode('path')
await handleLoadFromPath(savedPath.path)
}
}
} catch (error) {
console.error('加载保存的路径失败:', error)
}
}
loadSavedPath()
}, [handleLoadFromPath, handleLoadFromPreset])
// 自动保存配置到路径(防抖)
const autoSaveToPath = useCallback((updatedConfig: AdapterConfig) => {
if ((mode !== 'path' && mode !== 'preset') || !configPath) return
// 清除之前的定时器
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// 设置新的定时器1秒后保存
saveTimeoutRef.current = setTimeout(async () => {
setIsSaving(true)
try {
const tomlContent = generateTOML(updatedConfig)
await saveConfigToPath(configPath, tomlContent)
toast({
title: '自动保存成功',
description: '配置已保存到文件',
})
} catch (error) {
console.error('自动保存失败:', error)
toast({
title: '自动保存失败',
description: error instanceof Error ? error.message : '保存配置失败',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}, 1000)
}, [mode, configPath, toast])
// 手动保存配置
const handleManualSave = async () => {
if (!config || !configPath) return
// 再次验证路径
const validation = validatePath(configPath)
if (!validation.valid) {
toast({
title: '保存失败',
description: validation.error,
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const tomlContent = generateTOML(config)
await saveConfigToPath(configPath, tomlContent)
toast({
title: '保存成功',
description: '配置已保存到文件',
})
} catch (error) {
console.error('保存失败:', error)
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '保存配置失败',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
// 刷新配置(重新从文件加载)
const handleRefresh = async () => {
if (!configPath) return
await handleLoadFromPath(configPath)
}
// 切换模式
const handleModeChange = (newMode: 'upload' | 'path' | 'preset') => {
if (newMode === mode) return
// 如果有未保存的配置,显示确认对话框
if (config) {
setPendingMode(newMode)
setShowModeSwitchDialog(true)
return
}
// 直接切换模式
performModeSwitch(newMode)
}
// 执行模式切换
const performModeSwitch = (newMode: 'upload' | 'path' | 'preset') => {
setConfig(null)
setFileName('')
setPathError('')
setMode(newMode)
// 如果切换到预设模式,自动加载默认预设
if (newMode === 'preset') {
handleLoadFromPreset('oneclick')
}
const modeNames = {
upload: '现在可以上传配置文件',
path: '现在可以指定配置文件路径',
preset: '现在可以使用预设配置',
}
toast({
title: '已切换模式',
description: modeNames[newMode],
})
}
// 确认模式切换
const confirmModeSwitch = () => {
if (pendingMode) {
performModeSwitch(pendingMode)
setPendingMode(null)
}
setShowModeSwitchDialog(false)
}
// 清空路径
const handleClearPath = () => {
if (config) {
setShowClearPathDialog(true)
return
}
// 直接清空
performClearPath()
}
// 执行清空路径
const performClearPath = () => {
setConfigPath('')
setConfig(null)
setPathError('')
toast({
title: '已清空',
description: '路径和配置已清空',
})
}
// 确认清空路径
const confirmClearPath = () => {
performClearPath()
setShowClearPathDialog(false)
}
// 上传文件处理
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const parsedConfig = parseTOML(content)
setConfig(parsedConfig)
setFileName(file.name)
toast({
title: '上传成功',
description: `已加载配置文件:${file.name}`,
})
} catch (error) {
console.error('解析配置文件失败:', error)
toast({
title: '解析失败',
description: '配置文件格式错误,请检查文件内容',
variant: 'destructive',
})
}
}
reader.readAsText(file)
}
// 下载配置文件
const handleDownload = () => {
if (!config) return
const tomlContent = generateTOML(config)
const blob = new Blob([tomlContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName || 'config.toml'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({
title: '下载成功',
description: '配置文件已下载,请手动覆盖并重启适配器',
})
}
// 使用默认配置
const handleUseDefault = () => {
setConfig(JSON.parse(JSON.stringify(DEFAULT_CONFIG)))
setFileName('config.toml')
toast({
title: '已加载默认配置',
description: '可以开始编辑配置',
})
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
QQ
</p>
</div>
</div>
{/* 模式选择 */}
<Collapsible open={isModeConfigOpen} onOpenChange={setIsModeConfigOpen}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${
isModeConfigOpen ? 'transform rotate-180' : ''
}`} />
<span className="sr-only"></span>
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4" role="radiogroup" aria-label="部署模式选择">
{/* 预设模式 */}
<div
className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${
mode === 'preset'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'preset'}
tabIndex={0}
onClick={() => handleModeChange('preset')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('preset') } }}
>
<div className="flex items-start gap-2 md:gap-3">
<Package className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-sm md:text-base"></h3>
<p className="text-xs md:text-sm text-muted-foreground mt-1 line-clamp-2">
使
</p>
</div>
</div>
</div>
{/* 上传模式 */}
<div
className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${
mode === 'upload'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'upload'}
tabIndex={0}
onClick={() => handleModeChange('upload')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('upload') } }}
>
<div className="flex items-start gap-2 md:gap-3">
<Upload className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-sm md:text-base"></h3>
<p className="text-xs md:text-sm text-muted-foreground mt-1 line-clamp-2">
</p>
</div>
</div>
</div>
{/* 路径模式 */}
<div
className={`border-2 rounded-lg p-3 md:p-4 cursor-pointer transition-all ${
mode === 'path'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50 active:border-primary/70'
}`}
role="radio"
aria-checked={mode === 'path'}
tabIndex={0}
onClick={() => handleModeChange('path')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('path') } }}
>
<div className="flex items-start gap-2 md:gap-3">
<FolderOpen className="h-4 w-4 md:h-5 md:w-5 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-sm md:text-base"></h3>
<p className="text-xs md:text-sm text-muted-foreground mt-1 line-clamp-2">
</p>
</div>
</div>
</div>
</div>
{/* 预设模式配置 */}
{mode === 'preset' && (
<div className="space-y-3 pt-2 border-t">
<Label className="text-sm md:text-base"></Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(PRESETS).map(([key, preset]) => {
const Icon = preset.icon
const isSelected = selectedPreset === key
return (
<div
key={key}
className={`border-2 rounded-lg p-3 cursor-pointer transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-muted hover:border-primary/50'
}`}
role="radio"
aria-checked={isSelected}
tabIndex={0}
onClick={() => {
setSelectedPreset(key as PresetKey)
handleLoadFromPreset(key as PresetKey)
}}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPreset(key as PresetKey); handleLoadFromPreset(key as PresetKey) } }}
>
<div className="flex items-start gap-3">
<Icon className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-sm">{preset.name}</h4>
<p className="text-xs text-muted-foreground mt-1">
{preset.description}
</p>
<p className="text-xs text-muted-foreground mt-1 font-mono break-all">
{preset.path}
</p>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* 路径模式配置 */}
{mode === 'path' && (
<div className="space-y-3 pt-2 border-t">
<div className="space-y-2">
<Label htmlFor="config-path" className="text-sm md:text-base"></Label>
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1 space-y-1">
<Input
id="config-path"
value={configPath}
onChange={(e) => handlePathChange(e.target.value)}
placeholder="例: C:\Adapter\config.toml"
className={`text-sm ${pathError ? 'border-destructive' : ''}`}
/>
{pathError && (
<p className="text-xs text-destructive">{pathError}</p>
)}
</div>
<Button
onClick={() => handleLoadFromPath(configPath)}
disabled={isLoading || !configPath || !!pathError}
className="w-full sm:w-auto"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
<span className="sm:hidden">...</span>
</>
) : (
<>
<span className="sm:hidden"></span>
<span className="hidden sm:inline"></span>
</>
)}
</Button>
</div>
</div>
<details className="rounded-lg bg-muted/50 p-3 group">
<summary className="text-xs font-medium cursor-pointer select-none list-none flex items-center justify-between">
<span></span>
<svg className="h-4 w-4 transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="mt-2 space-y-2 text-xs text-muted-foreground">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono bg-background px-1.5 py-0.5 rounded text-[10px] md:text-xs whitespace-nowrap">Windows</span>
</div>
<div className="pl-2 space-y-0.5 text-[10px] md:text-xs break-all">
<div>C:\Adapter\config.toml</div>
<div className="hidden sm:block">D:\MaiBot\adapter\config.toml</div>
<div className="hidden sm:block">\\server\share\config.toml</div>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono bg-background px-1.5 py-0.5 rounded text-[10px] md:text-xs whitespace-nowrap">Linux</span>
</div>
<div className="pl-2 space-y-0.5 text-[10px] md:text-xs break-all">
<div>/opt/adapter/config.toml</div>
<div className="hidden sm:block">/home/user/adapter/config.toml</div>
<div className="hidden sm:block">~/adapter/config.toml</div>
</div>
</div>
<p className="pt-1 border-t text-[10px] md:text-xs">
💡 1
</p>
</div>
</details>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* 操作提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{mode === 'preset' ? (
<>
<strong></strong> 1 {isSaving && ' (正在保存...)'}
</>
) : mode === 'upload' ? (
<>
<strong></strong> 线
</>
) : (
<>
<strong></strong> 1 {isSaving && ' (正在保存...)'}
</>
)}
</AlertDescription>
</Alert>
{/* 上传模式的操作按钮 */}
{mode === 'upload' && !config && (
<div className="flex flex-col sm:flex-row gap-2 w-full">
<input
ref={fileInputRef}
type="file"
accept=".toml"
className="hidden"
onChange={handleFileUpload}
/>
<Button
onClick={() => fileInputRef.current?.click()}
size="sm"
variant="outline"
className="w-full sm:w-auto"
>
<Upload className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleUseDefault} size="sm" className="w-full sm:w-auto">
<FileText className="mr-2 h-4 w-4" />
使
</Button>
</div>
)}
{/* 上传模式的下载按钮 */}
{mode === 'upload' && config && (
<div className="flex gap-2">
<Button onClick={handleDownload} size="sm" className="w-full sm:w-auto">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
)}
{/* 预设和路径模式的操作按钮 */}
{(mode === 'preset' || mode === 'path') && config && (
<div className="flex flex-col sm:flex-row gap-2">
<Button onClick={handleManualSave} size="sm" disabled={isSaving || !!pathError} className="w-full sm:w-auto">
<Save className="mr-2 h-4 w-4" />
{isSaving ? '保存中...' : '立即保存'}
</Button>
<Button onClick={handleRefresh} size="sm" variant="outline" disabled={isLoading} className="w-full sm:w-auto">
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
{mode === 'path' && (
<Button onClick={handleClearPath} size="sm" variant="destructive" className="w-full sm:w-auto">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
{/* 配置编辑区域 */}
{!config ? (
<div className="rounded-lg border bg-card p-6 md:p-12">
<div className="text-center space-y-3 md:space-y-4">
<FileText className="h-12 w-12 md:h-16 md:w-16 mx-auto text-muted-foreground" />
<div>
<h3 className="text-base md:text-lg font-semibold"></h3>
<p className="text-xs md:text-sm text-muted-foreground mt-2 px-4">
{mode === 'preset'
? '请选择预设的部署方式'
: mode === 'upload'
? '请上传现有配置文件,或使用默认配置开始编辑'
: '请指定配置文件路径并点击加载按钮'}
</p>
</div>
</div>
</div>
) : (
<Tabs defaultValue="napcat" className="w-full">
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
<TabsList className="inline-flex w-auto min-w-full sm:grid sm:w-full sm:grid-cols-5">
<TabsTrigger value="napcat" className="flex-shrink-0 text-xs sm:text-sm whitespace-nowrap">
<span className="hidden sm:inline">Napcat </span>
<span className="sm:hidden">Napcat</span>
</TabsTrigger>
<TabsTrigger value="maibot" className="flex-shrink-0 text-xs sm:text-sm whitespace-nowrap">
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="chat" className="flex-shrink-0 text-xs sm:text-sm whitespace-nowrap">
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="voice" className="flex-shrink-0 text-xs sm:text-sm whitespace-nowrap">
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="debug" className="flex-shrink-0 text-xs sm:text-sm whitespace-nowrap"></TabsTrigger>
</TabsList>
</div>
{/* Napcat 服务器配置 */}
<TabsContent value="napcat" className="space-y-4">
<NapcatServerSection
config={config}
onChange={(newConfig) => {
setConfig(newConfig)
autoSaveToPath(newConfig)
}}
/>
</TabsContent>
{/* 麦麦服务器配置 */}
<TabsContent value="maibot" className="space-y-4">
<MaiBotServerSection
config={config}
onChange={(newConfig) => {
setConfig(newConfig)
autoSaveToPath(newConfig)
}}
/>
</TabsContent>
{/* 聊天控制配置 */}
<TabsContent value="chat" className="space-y-4">
<ChatControlSection
config={config}
onChange={(newConfig) => {
setConfig(newConfig)
autoSaveToPath(newConfig)
}}
/>
</TabsContent>
{/* 语音配置 */}
<TabsContent value="voice" className="space-y-4">
<VoiceSection
config={config}
onChange={(newConfig) => {
setConfig(newConfig)
autoSaveToPath(newConfig)
}}
/>
</TabsContent>
{/* 调试配置 */}
<TabsContent value="debug" className="space-y-4">
<DebugSection
config={config}
onChange={(newConfig) => {
setConfig(newConfig)
autoSaveToPath(newConfig)
}}
/>
</TabsContent>
</Tabs>
)}
{/* 模式切换确认对话框 */}
<AlertDialog open={showModeSwitchDialog} onOpenChange={setShowModeSwitchDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<br />
<span className="text-destructive font-medium"></span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setShowModeSwitchDialog(false)
setPendingMode(null)
}}>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmModeSwitch}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 清空路径确认对话框 */}
<AlertDialog open={showClearPathDialog} onOpenChange={setShowClearPathDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<br />
<span className="text-muted-foreground text-sm"></span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowClearPathDialog(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmClearPath} className="bg-destructive hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ScrollArea>
)
}
// Napcat 服务器配置组件
function NapcatServerSection({
config,
onChange,
}: {
config: AdapterConfig
onChange: (config: AdapterConfig) => void
}) {
return (
<div className="rounded-lg border bg-card p-4 md:p-6 space-y-4 md:space-y-6">
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4">Napcat WebSocket </h3>
<div className="grid gap-3 md:gap-4">
<div className="grid gap-2">
<Label htmlFor="napcat-host" className="text-sm md:text-base"></Label>
<Input
id="napcat-host"
value={config.napcat_server.host}
onChange={(e) =>
onChange({
...config,
napcat_server: { ...config.napcat_server, host: e.target.value },
})
}
placeholder="localhost"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground">Napcat </p>
</div>
<div className="grid gap-2">
<Label htmlFor="napcat-port" className="text-sm md:text-base"></Label>
<Input
id="napcat-port"
type="number"
value={config.napcat_server.port || ''}
onChange={(e) =>
onChange({
...config,
napcat_server: { ...config.napcat_server, port: e.target.value ? parseInt(e.target.value) : 0 },
})
}
placeholder="8095"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground">Napcat 使 8095</p>
</div>
<div className="grid gap-2">
<Label htmlFor="napcat-token" className="text-sm md:text-base">访Token</Label>
<Input
id="napcat-token"
type="password"
value={config.napcat_server.token}
onChange={(e) =>
onChange({
...config,
napcat_server: { ...config.napcat_server, token: e.target.value },
})
}
placeholder="留空表示无需令牌"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground">Napcat 访</p>
</div>
<div className="grid gap-2">
<Label htmlFor="napcat-heartbeat" className="text-sm md:text-base"></Label>
<Input
id="napcat-heartbeat"
type="number"
value={config.napcat_server.heartbeat_interval || ''}
onChange={(e) =>
onChange({
...config,
napcat_server: {
...config.napcat_server,
heartbeat_interval: e.target.value ? parseInt(e.target.value) : 0,
},
})
}
placeholder="30"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground"> Napcat 使 30</p>
</div>
</div>
</div>
</div>
)
}
// 麦麦服务器配置组件
function MaiBotServerSection({
config,
onChange,
}: {
config: AdapterConfig
onChange: (config: AdapterConfig) => void
}) {
return (
<div className="rounded-lg border bg-card p-4 md:p-6 space-y-4 md:space-y-6">
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4"> WebSocket </h3>
<div className="grid gap-3 md:gap-4">
<div className="grid gap-2">
<Label htmlFor="maibot-host" className="text-sm md:text-base"></Label>
<Input
id="maibot-host"
value={config.maibot_server.host}
onChange={(e) =>
onChange({
...config,
maibot_server: { ...config.maibot_server, host: e.target.value },
})
}
placeholder="localhost"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground"> .env HOST </p>
</div>
<div className="grid gap-2">
<Label htmlFor="maibot-port" className="text-sm md:text-base"></Label>
<Input
id="maibot-port"
type="number"
value={config.maibot_server.port || ''}
onChange={(e) =>
onChange({
...config,
maibot_server: { ...config.maibot_server, port: e.target.value ? parseInt(e.target.value) : 0 },
})
}
placeholder="8000"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground"> .env PORT 使 8000</p>
</div>
</div>
</div>
</div>
)
}
// 聊天控制配置组件
function ChatControlSection({
config,
onChange,
}: {
config: AdapterConfig
onChange: (config: AdapterConfig) => void
}) {
const addToList = (listType: 'group' | 'private' | 'ban') => {
const newConfig = { ...config }
if (listType === 'group') {
newConfig.chat.group_list = [...newConfig.chat.group_list, 0]
} else if (listType === 'private') {
newConfig.chat.private_list = [...newConfig.chat.private_list, 0]
} else {
newConfig.chat.ban_user_id = [...newConfig.chat.ban_user_id, 0]
}
onChange(newConfig)
}
const removeFromList = (listType: 'group' | 'private' | 'ban', index: number) => {
const newConfig = { ...config }
if (listType === 'group') {
newConfig.chat.group_list = newConfig.chat.group_list.filter((_, i) => i !== index)
} else if (listType === 'private') {
newConfig.chat.private_list = newConfig.chat.private_list.filter((_, i) => i !== index)
} else {
newConfig.chat.ban_user_id = newConfig.chat.ban_user_id.filter((_, i) => i !== index)
}
onChange(newConfig)
}
const updateListItem = (listType: 'group' | 'private' | 'ban', index: number, value: number) => {
const newConfig = { ...config }
if (listType === 'group') {
newConfig.chat.group_list[index] = value
} else if (listType === 'private') {
newConfig.chat.private_list[index] = value
} else {
newConfig.chat.ban_user_id[index] = value
}
onChange(newConfig)
}
return (
<div className="rounded-lg border bg-card p-4 md:p-6 space-y-4 md:space-y-6">
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4"></h3>
<div className="grid gap-4 md:gap-6">
{/* 群组名单 */}
<div className="space-y-3 md:space-y-4">
<div className="grid gap-2">
<Label className="text-sm md:text-base"></Label>
<Select
value={config.chat.group_list_type}
onValueChange={(value: 'whitelist' | 'blacklist') =>
onChange({
...config,
chat: { ...config.chat, group_list_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="whitelist"></SelectItem>
<SelectItem value="blacklist"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<Label className="text-sm md:text-base"></Label>
<Button onClick={() => addToList('group')} size="sm" variant="outline" className="w-full sm:w-auto">
<FileText className="mr-1 h-4 w-4" />
</Button>
</div>
{config.chat.group_list.map((groupId, index) => (
<div key={index} className="flex gap-2">
<Input
type="number"
value={groupId}
onChange={(e) => updateListItem('group', index, parseInt(e.target.value) || 0)}
placeholder="输入群号"
className="text-sm md:text-base"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{groupId}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeFromList('group', index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{config.chat.group_list.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* 私聊名单 */}
<div className="space-y-3 md:space-y-4">
<div className="grid gap-2">
<Label className="text-sm md:text-base"></Label>
<Select
value={config.chat.private_list_type}
onValueChange={(value: 'whitelist' | 'blacklist') =>
onChange({
...config,
chat: { ...config.chat, private_list_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="whitelist"></SelectItem>
<SelectItem value="blacklist"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<Label className="text-sm md:text-base"></Label>
<Button onClick={() => addToList('private')} size="sm" variant="outline" className="w-full sm:w-auto">
<FileText className="mr-1 h-4 w-4" />
</Button>
</div>
{config.chat.private_list.map((userId, index) => (
<div key={index} className="flex gap-2">
<Input
type="number"
value={userId}
onChange={(e) => updateListItem('private', index, parseInt(e.target.value) || 0)}
placeholder="输入QQ号"
className="text-sm md:text-base"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{userId}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeFromList('private', index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{config.chat.private_list.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* 全局禁止名单 */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<div>
<Label className="text-sm md:text-base"></Label>
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<Button onClick={() => addToList('ban')} size="sm" variant="outline" className="w-full sm:w-auto">
<FileText className="mr-1 h-4 w-4" />
</Button>
</div>
{config.chat.ban_user_id.map((userId, index) => (
<div key={index} className="flex gap-2">
<Input
type="number"
value={userId}
onChange={(e) => updateListItem('ban', index, parseInt(e.target.value) || 0)}
placeholder="输入QQ号"
className="text-sm md:text-base"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{userId}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeFromList('ban', index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{config.chat.ban_user_id.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* 其他设置 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm md:text-base">QQ官方机器人</Label>
<p className="text-xs text-muted-foreground mt-1">QQ官方机器人的消息</p>
</div>
<Switch
checked={config.chat.ban_qq_bot}
onCheckedChange={(checked) =>
onChange({
...config,
chat: { ...config.chat, ban_qq_bot: checked },
})
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm md:text-base"></Label>
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<Switch
checked={config.chat.enable_poke}
onCheckedChange={(checked) =>
onChange({
...config,
chat: { ...config.chat, enable_poke: checked },
})
}
/>
</div>
</div>
</div>
</div>
)
}
// 语音和转发消息配置组件
function VoiceSection({
config,
onChange,
}: {
config: AdapterConfig
onChange: (config: AdapterConfig) => void
}) {
return (
<div className="rounded-lg border bg-card p-4 md:p-6 space-y-4 md:space-y-6">
{/* 语音设置 */}
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4"></h3>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm md:text-base">使 TTS </Label>
<p className="text-xs text-muted-foreground mt-1">
TTS
</p>
</div>
<Switch
checked={config.voice.use_tts}
onCheckedChange={(checked) =>
onChange({
...config,
voice: { ...config.voice, use_tts: checked },
})
}
/>
</div>
</div>
{/* 转发消息处理设置 */}
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4"></h3>
<div className="grid gap-2">
<Label htmlFor="image-threshold" className="text-sm md:text-base"></Label>
<Input
id="image-threshold"
type="number"
value={config.forward.image_threshold || ''}
onChange={(e) =>
onChange({
...config,
forward: {
...config.forward,
image_threshold: e.target.value ? parseInt(e.target.value) : 0
},
})
}
placeholder="30"
className="text-sm md:text-base"
/>
<p className="text-xs text-muted-foreground">
使VLM处理卡死
</p>
</div>
</div>
</div>
)
}
// 调试配置组件
function DebugSection({
config,
onChange,
}: {
config: AdapterConfig
onChange: (config: AdapterConfig) => void
}) {
return (
<div className="rounded-lg border bg-card p-4 md:p-6 space-y-4 md:space-y-6">
<div>
<h3 className="text-base md:text-lg font-semibold mb-3 md:mb-4"></h3>
<div className="grid gap-3 md:gap-4">
<div className="grid gap-2">
<Label className="text-sm md:text-base"></Label>
<Select
value={config.debug.level}
onValueChange={(value) =>
onChange({
...config,
debug: { level: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEBUG">DEBUG</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
<SelectItem value="WARNING">WARNING</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
<SelectItem value="CRITICAL">CRITICAL</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</div>
</div>
)
}