feat(electron): add Electron UI components and layout integration
This commit is contained in:
244
dashboard/src/components/electron/BackendManager.tsx
Normal file
244
dashboard/src/components/electron/BackendManager.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal file
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
dashboard/src/components/electron/TitleBar.tsx
Normal file
64
dashboard/src/components/electron/TitleBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { BackgroundLayer } from '@/components/background-layer'
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Kbd } from '@/components/ui/kbd'
|
import { Kbd } from '@/components/ui/kbd'
|
||||||
import { SearchDialog } from '@/components/search-dialog'
|
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 { cn } from '@/lib/utils'
|
||||||
import { useBackground } from '@/hooks/use-background'
|
import { useBackground } from '@/hooks/use-background'
|
||||||
import { logout } from '@/lib/fetch-with-auth'
|
import { logout } from '@/lib/fetch-with-auth'
|
||||||
@@ -32,6 +35,15 @@ export function Header({
|
|||||||
onThemeChange,
|
onThemeChange,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const headerBg = useBackground('header')
|
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 () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -62,6 +74,25 @@ export function Header({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Link to="/annual-report">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { useTheme } from '@/components/use-theme'
|
|||||||
import { useAuthGuard } from '@/hooks/use-auth'
|
import { useAuthGuard } from '@/hooks/use-auth'
|
||||||
import { useBackground } from '@/hooks/use-background'
|
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 { Header } from './Header'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import type { LayoutProps } from './types'
|
import type { LayoutProps } from './types'
|
||||||
@@ -70,7 +73,8 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<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 */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
|
|||||||
62
dashboard/src/hooks/useBackendConnections.ts
Normal file
62
dashboard/src/hooks/useBackendConnections.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
dashboard/src/hooks/useWindowControls.ts
Normal file
30
dashboard/src/hooks/useWindowControls.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
@@ -9,6 +9,18 @@ import { AnimationProvider } from './components/animation-provider'
|
|||||||
import { TourProvider, TourRenderer } from './components/tour'
|
import { TourProvider, TourRenderer } from './components/tour'
|
||||||
import { Toaster } from './components/ui/toaster'
|
import { Toaster } from './components/ui/toaster'
|
||||||
import { ErrorBoundary } from './components/error-boundary'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -17,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<ThemeProvider defaultTheme="system">
|
<ThemeProvider defaultTheme="system">
|
||||||
<AnimationProvider>
|
<AnimationProvider>
|
||||||
<TourProvider>
|
<TourProvider>
|
||||||
|
{isElectron() && <ElectronShell />}
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<TourRenderer />
|
<TourRenderer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
Reference in New Issue
Block a user