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 { 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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { 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 />
|
||||
|
||||
Reference in New Issue
Block a user