feat(electron): add Electron UI components and layout integration

This commit is contained in:
DrSmoothl
2026-03-03 00:54:24 +08:00
parent fc394f4412
commit 65774f3afc
8 changed files with 708 additions and 4 deletions

View File

@@ -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<Partial<BackendConnection> | null>(null)
const [deleteConn, setDeleteConn] = useState<BackendConnection | null>(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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-[60vh] pr-4">
<div className="flex flex-col gap-3 py-4">
{backends.map((backend) => {
const isActive = backend.id === activeId
return (
<div
key={backend.id}
className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-border'
}`}
>
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
{isActive ? (
<Check className="h-5 w-5 text-blue-500" />
) : (
<div className="h-3 w-3 rounded-full bg-muted-foreground/30 ml-1" title="未知状态" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium leading-none">
{backend.name}
</span>
<span className="truncate text-xs text-muted-foreground mt-1">
{backend.url}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{!isActive && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSwitch(backend.id)}
title="切换到此后端"
>
<Server className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditConn(backend)}
title="编辑"
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeleteConn(backend)}
disabled={isActive}
title={isActive ? '无法删除活跃后端' : '删除'}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
)
})}
</div>
</ScrollArea>
)}
<div className="flex justify-end pt-4 border-t">
<Button
className="w-full"
onClick={() => setEditConn({ name: '', url: 'http://', isDefault: false })}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit/Add Dialog */}
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={editConn?.name || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
placeholder="我的服务器"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<Input
id="url"
value={editConn?.url || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null))
}
placeholder="http://192.168.1.100:8001"
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setEditConn(null)}>
</Button>
<Button
onClick={handleSave}
disabled={
!editConn?.name ||
!editConn?.url ||
!/^https?:\/\//.test(editConn.url)
}
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteConn} onOpenChange={(open) => !open && setDeleteConn(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteConn?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -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<TestStatus>('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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
<Card className="relative z-10 max-w-md w-full mx-4 shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">使 MaiBot</CardTitle>
<CardDescription>
使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Backend name field */}
<div className="space-y-2">
<Label htmlFor="backend-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-name"
placeholder="例如:本地服务器"
value={name}
onChange={(e) => {
setName(e.target.value)
if (nameError) validateName(e.target.value)
}}
onBlur={() => validateName(name)}
/>
{nameError && (
<p className="text-sm text-destructive">{nameError}</p>
)}
</div>
{/* Backend URL field */}
<div className="space-y-2">
<Label htmlFor="backend-url">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-url"
placeholder="例如http://192.168.1.100:8001"
value={url}
onChange={(e) => {
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 && (
<p className="text-sm text-destructive">{urlError}</p>
)}
</div>
{/* Test connection */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testStatus === 'loading' || !url.trim()}
className="w-full"
>
{testStatus === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
'测试连接'
)}
</Button>
{testStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
</div>
)}
{testStatus === 'error' && (
<div className="flex items-start gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{testError || '无法连接'}</span>
</div>
)}
</div>
{/* Submit button */}
<Button
onClick={handleFinish}
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
使
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -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 (
<div
className={`flex items-center justify-between border-b border-border bg-background select-none ${isMac ? 'h-7' : 'h-8'}`}
style={dragStyle}
>
{/* macOS traffic light padding */}
{isMac && <div className="h-full w-[78px]" style={noDragStyle} />}
{/* Title / Drag area */}
<div className="flex flex-1 items-center justify-center text-xs font-semibold text-foreground/80">
MaiBot
</div>
{/* Windows / Linux Controls */}
{!isMac && (
<div className="flex h-full items-center" style={noDragStyle}>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={minimize}
tabIndex={-1}
type="button"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={toggleMaximize}
tabIndex={-1}
type="button"
>
{isMaximized ? (
<Copy className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-destructive hover:text-destructive-foreground"
onClick={close}
tabIndex={-1}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
}

View File

@@ -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<string>('')
useEffect(() => {
if (!isElectron()) return
window.electronAPI!.getActiveBackend().then((b) => {
setActiveBackendName(b?.name ?? '未连接')
})
}, [])
const handleLogout = async () => {
await logout()
@@ -62,6 +74,25 @@ export function Header({
</div>
<div className="flex items-center gap-2">
{/* 后端切换按钮(仅 Electron */}
{isElectron() && (
<>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setBackendManagerOpen(true)}
title="切换后端连接"
>
<Server className="h-4 w-4" />
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-[100px]">
{activeBackendName}
</span>
</Button>
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
<div className="h-6 w-px bg-border" />
</>
)}
{/* 年度总结入口 */}
<Link to="/annual-report">
<Button

View File

@@ -8,6 +8,9 @@ import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types'
@@ -70,7 +73,8 @@ export function Layout({ children }: LayoutProps) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
{isElectron() && <TitleBar />}
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
{/* Sidebar */}
<Sidebar
sidebarOpen={sidebarOpen}
@@ -113,7 +117,7 @@ export function Layout({ children }: LayoutProps) {
{/* Back to Top Button */}
<BackToTop />
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -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<BackendConnection[]>([])
const [activeId, setActiveId] = useState<string | null>(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<BackendConnection, 'id'>) => {
if (!isElectron()) return
await window.electronAPI!.addBackend(conn)
await refresh()
}, [refresh])
const updateBackend = useCallback(async (id: string, patch: Partial<BackendConnection>) => {
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
}
}

View File

@@ -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 }
}

View File

@@ -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 <BackendSetupWizard open={isFirstLaunch} />
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -17,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider defaultTheme="system">
<AnimationProvider>
<TourProvider>
{isElectron() && <ElectronShell />}
<RouterProvider router={router} />
<TourRenderer />
<Toaster />