diff --git a/dashboard/src/components/electron/BackendManager.tsx b/dashboard/src/components/electron/BackendManager.tsx new file mode 100644 index 00000000..caef5137 --- /dev/null +++ b/dashboard/src/components/electron/BackendManager.tsx @@ -0,0 +1,244 @@ +import { useState } from 'react' +import { Check, Loader2, Pencil, Plus, Server, Trash2 } from 'lucide-react' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { useBackendConnections } from '@/hooks/useBackendConnections' +import { isElectron } from '@/lib/runtime' +import type { BackendConnection } from '@/types/electron' + +export interface BackendManagerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function BackendManager({ open, onOpenChange }: BackendManagerProps) { + const { + activeId, + addBackend, + backends, + loading, + removeBackend, + switchBackend, + updateBackend, + } = useBackendConnections() + + const [editConn, setEditConn] = useState | null>(null) + const [deleteConn, setDeleteConn] = useState(null) + + if (!isElectron()) return null + + const handleSave = async () => { + if (!editConn?.name || !editConn?.url) return + const urlPattern = /^https?:\/\// + if (!urlPattern.test(editConn.url)) return + + if (editConn.id) { + await updateBackend(editConn.id, editConn) + } else { + await addBackend({ + name: editConn.name, + url: editConn.url, + isDefault: editConn.isDefault ?? false, + }) + } + setEditConn(null) + } + + const handleDelete = async () => { + if (!deleteConn) return + if (deleteConn.id === activeId) return + await removeBackend(deleteConn.id) + setDeleteConn(null) + } + + const handleSwitch = async (id: string) => { + if (id === activeId) return + await switchBackend(id) + } + + return ( + <> + + + + 后端连接管理 + + + {loading ? ( +
+ +
+ ) : ( + +
+ {backends.map((backend) => { + const isActive = backend.id === activeId + return ( +
+
+
+ {isActive ? ( + + ) : ( +
+ )} +
+
+ + {backend.name} + + + {backend.url} + +
+
+ +
+ {!isActive && ( + + )} + + +
+
+ ) + })} +
+ + )} + +
+ +
+ +
+ + {/* Edit/Add Dialog */} + !open && setEditConn(null)}> + + + {editConn?.id ? '编辑连接' : '添加连接'} + +
+
+ + + setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null)) + } + placeholder="我的服务器" + /> +
+
+ + + setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null)) + } + placeholder="http://192.168.1.100:8001" + /> +
+
+
+ + +
+
+
+ + {/* Delete Confirmation */} + !open && setDeleteConn(null)}> + + + 删除连接 + + 确定要删除 {deleteConn?.name} 吗?此操作不可撤销。 + + + + 取消 + + 删除 + + + + + + ) +} diff --git a/dashboard/src/components/electron/BackendSetupWizard.tsx b/dashboard/src/components/electron/BackendSetupWizard.tsx new file mode 100644 index 00000000..c1fcfa18 --- /dev/null +++ b/dashboard/src/components/electron/BackendSetupWizard.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { + ArrowRight, + Bot, + CheckCircle2, + Loader2, + XCircle, +} from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +import { isElectron } from '@/lib/runtime' + +interface BackendSetupWizardProps { + open: boolean +} + +type TestStatus = 'idle' | 'loading' | 'success' | 'error' + +/** + * First-launch backend setup wizard for Electron environment. + * Full-screen modal that guides users to configure their first backend connection. + * Cannot be dismissed until configuration is complete. + */ +export function BackendSetupWizard({ open }: BackendSetupWizardProps) { + const [name, setName] = useState('') + const [url, setUrl] = useState('') + const [testStatus, setTestStatus] = useState('idle') + const [testError, setTestError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Validation errors + const [nameError, setNameError] = useState('') + const [urlError, setUrlError] = useState('') + + // Only render in Electron environment + if (!isElectron()) { + return null + } + + if (!open) { + return null + } + + const validateName = (value: string): boolean => { + if (!value.trim()) { + setNameError('后端名称不能为空') + return false + } + setNameError('') + return true + } + + const validateUrl = (value: string): boolean => { + if (!value.trim()) { + setUrlError('后端地址不能为空') + return false + } + if (!/^https?:\/\/.+/.test(value)) { + setUrlError('地址必须以 http:// 或 https:// 开头') + return false + } + if (value.endsWith('/')) { + setUrlError('地址末尾不能包含 /') + return false + } + setUrlError('') + return true + } + + const handleTestConnection = async () => { + if (!validateUrl(url)) return + + setTestStatus('loading') + setTestError('') + + try { + const response = await fetch(`${url}/api/webui/system/health`, { + method: 'GET', + signal: AbortSignal.timeout(10000), + }) + if (response.ok) { + setTestStatus('success') + } else { + setTestStatus('error') + setTestError(`服务器返回状态码 ${response.status}`) + } + } catch (err) { + setTestStatus('error') + if (err instanceof DOMException && err.name === 'TimeoutError') { + setTestError('连接超时,请检查地址是否正确') + } else if (err instanceof TypeError) { + setTestError('无法连接到服务器,请检查地址和网络') + } else { + setTestError(err instanceof Error ? err.message : '未知错误') + } + } + } + + const handleFinish = async () => { + const isNameValid = validateName(name) + const isUrlValid = validateUrl(url) + if (!isNameValid || !isUrlValid) return + + setIsSubmitting(true) + try { + const newBackend = await window.electronAPI!.addBackend({ + name: name.trim(), + url: url.trim(), + isDefault: true, + }) + await window.electronAPI!.setActiveBackend(newBackend.id) + await window.electronAPI!.markFirstLaunchComplete() + window.location.reload() + } catch (err) { + setIsSubmitting(false) + setTestStatus('error') + setTestError( + err instanceof Error ? err.message : '保存配置失败,请重试' + ) + } + } + + const isFormValid = name.trim() !== '' && /^https?:\/\/.+/.test(url) && !url.endsWith('/') + + return ( +
+ {/* Background decoration */} +
+
+
+
+ + + +
+ +
+ 欢迎使用 MaiBot + + 配置您的第一个后端连接以开始使用 + +
+ + + {/* Backend name field */} +
+ + { + setName(e.target.value) + if (nameError) validateName(e.target.value) + }} + onBlur={() => validateName(name)} + /> + {nameError && ( +

{nameError}

+ )} +
+ + {/* Backend URL field */} +
+ + { + setUrl(e.target.value) + if (urlError) validateUrl(e.target.value) + // Reset test status when URL changes + if (testStatus !== 'idle') { + setTestStatus('idle') + setTestError('') + } + }} + onBlur={() => validateUrl(url)} + /> + {urlError && ( +

{urlError}

+ )} +
+ + {/* Test connection */} +
+ + + {testStatus === 'success' && ( +
+ + 连接成功 +
+ )} + + {testStatus === 'error' && ( +
+ + {testError || '无法连接'} +
+ )} +
+ + {/* Submit button */} + +
+
+
+ ) +} diff --git a/dashboard/src/components/electron/TitleBar.tsx b/dashboard/src/components/electron/TitleBar.tsx new file mode 100644 index 00000000..8fe43480 --- /dev/null +++ b/dashboard/src/components/electron/TitleBar.tsx @@ -0,0 +1,64 @@ +import { Copy, Minus, Square, X } from 'lucide-react' +import { useMemo } from 'react' + +import { useWindowControls } from '@/hooks/useWindowControls' +import { getPlatform, isElectron } from '@/lib/runtime' + +const dragStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties & { WebkitAppRegion: string } +const noDragStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion: string } + +export function TitleBar() { + const { close, isMaximized, minimize, toggleMaximize } = useWindowControls() + const isMac = useMemo(() => getPlatform() === 'darwin', []) + + if (!isElectron()) return null + + return ( +
+ {/* macOS traffic light padding */} + {isMac &&
} + + {/* Title / Drag area */} +
+ MaiBot +
+ + {/* Windows / Linux Controls */} + {!isMac && ( +
+ + + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx index efacb33d..2b9493ca 100644 --- a/dashboard/src/components/layout/Header.tsx +++ b/dashboard/src/components/layout/Header.tsx @@ -1,10 +1,13 @@ -import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Sun } from 'lucide-react' +import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react' import { Link } from '@tanstack/react-router' import { BackgroundLayer } from '@/components/background-layer' import { Button } from '@/components/ui/button' import { Kbd } from '@/components/ui/kbd' import { SearchDialog } from '@/components/search-dialog' +import { useEffect, useState } from 'react' +import { BackendManager } from '@/components/electron/BackendManager' +import { isElectron } from '@/lib/runtime' import { cn } from '@/lib/utils' import { useBackground } from '@/hooks/use-background' import { logout } from '@/lib/fetch-with-auth' @@ -32,6 +35,15 @@ export function Header({ onThemeChange, }: HeaderProps) { const headerBg = useBackground('header') + const [backendManagerOpen, setBackendManagerOpen] = useState(false) + const [activeBackendName, setActiveBackendName] = useState('') + + useEffect(() => { + if (!isElectron()) return + window.electronAPI!.getActiveBackend().then((b) => { + setActiveBackendName(b?.name ?? '未连接') + }) + }, []) const handleLogout = async () => { await logout() @@ -62,6 +74,25 @@ export function Header({
+ {/* 后端切换按钮(仅 Electron) */} + {isElectron() && ( + <> + + +
+ + )} {/* 年度总结入口 */}
) } diff --git a/dashboard/src/hooks/useBackendConnections.ts b/dashboard/src/hooks/useBackendConnections.ts new file mode 100644 index 00000000..7f506109 --- /dev/null +++ b/dashboard/src/hooks/useBackendConnections.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react' + +import { isElectron } from '@/lib/runtime' +import type { BackendConnection } from '@/types/electron' + +export function useBackendConnections() { + const [backends, setBackends] = useState([]) + const [activeId, setActiveId] = useState(null) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + if (!isElectron()) return + const [list, active] = await Promise.all([ + window.electronAPI!.getBackends(), + window.electronAPI!.getActiveBackend(), + ]) + setBackends(list) + setActiveId(active?.id ?? null) + setLoading(false) + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + const addBackend = useCallback(async (conn: Omit) => { + if (!isElectron()) return + await window.electronAPI!.addBackend(conn) + await refresh() + }, [refresh]) + + const updateBackend = useCallback(async (id: string, patch: Partial) => { + if (!isElectron()) return + await window.electronAPI!.updateBackend(id, patch) + await refresh() + }, [refresh]) + + const removeBackend = useCallback(async (id: string) => { + if (!isElectron()) return + await window.electronAPI!.removeBackend(id) + await refresh() + }, [refresh]) + + const switchBackend = useCallback(async (id: string) => { + if (!isElectron()) return + await window.electronAPI!.setActiveBackend(id) + setActiveId(id) + // 重新加载页面以使用新后端 + window.location.reload() + }, []) + + return { + backends, + activeId, + loading, + addBackend, + updateBackend, + removeBackend, + switchBackend, + refresh + } +} diff --git a/dashboard/src/hooks/useWindowControls.ts b/dashboard/src/hooks/useWindowControls.ts new file mode 100644 index 00000000..7cc49418 --- /dev/null +++ b/dashboard/src/hooks/useWindowControls.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from 'react' + +import { isElectron } from '@/lib/runtime' + +export function useWindowControls() { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (!isElectron()) return + + const api = window.electronAPI + if (!api) return + + api.isMaximized().then(setIsMaximized) + + const unsubMax = api.onWindowMaximized(() => setIsMaximized(true)) + const unsubUnmax = api.onWindowUnmaximized(() => setIsMaximized(false)) + + return () => { + unsubMax?.() + unsubUnmax?.() + } + }, []) + + const minimize = useCallback(() => window.electronAPI?.minimizeWindow(), []) + const toggleMaximize = useCallback(() => window.electronAPI?.maximizeWindow(), []) + const close = useCallback(() => window.electronAPI?.closeWindow(), []) + + return { close, isMaximized, minimize, toggleMaximize } +} \ No newline at end of file diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index b5e77323..0123c786 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -1,4 +1,4 @@ -import { StrictMode } from 'react' +import { StrictMode, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { RouterProvider } from '@tanstack/react-router' import './index.css' @@ -9,6 +9,18 @@ import { AnimationProvider } from './components/animation-provider' import { TourProvider, TourRenderer } from './components/tour' import { Toaster } from './components/ui/toaster' import { ErrorBoundary } from './components/error-boundary' +import { BackendSetupWizard } from './components/electron/BackendSetupWizard' +import { isElectron } from './lib/runtime' + +function ElectronShell() { + const [isFirstLaunch, setIsFirstLaunch] = useState(false) + + useEffect(() => { + window.electronAPI!.isFirstLaunch().then(setIsFirstLaunch) + }, []) + + return +} createRoot(document.getElementById('root')!).render( @@ -17,6 +29,7 @@ createRoot(document.getElementById('root')!).render( + {isElectron() && }