Initial commit
This commit is contained in:
5957
src/components/settings/acp-agent-settings.tsx
Normal file
5957
src/components/settings/acp-agent-settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
80
src/components/settings/appearance-settings.tsx
Normal file
80
src/components/settings/appearance-settings.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
type ThemeMode = "system" | "light" | "dark"
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme()
|
||||
const resolvedThemeLabel =
|
||||
resolvedTheme === "dark"
|
||||
? "深色"
|
||||
: resolvedTheme === "light"
|
||||
? "浅色"
|
||||
: "--"
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">主题外观</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
选择浅色、深色或跟随系统主题,设置会自动保存。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
主题模式
|
||||
</label>
|
||||
<Select
|
||||
value={theme ?? "system"}
|
||||
onValueChange={(value) => setTheme(value as ThemeMode)}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="请选择主题模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="system">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
跟随系统
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
浅色
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
深色
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
当前生效主题:{resolvedThemeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1345
src/components/settings/mcp-settings.tsx
Normal file
1345
src/components/settings/mcp-settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
144
src/components/settings/settings-shell.tsx
Normal file
144
src/components/settings/settings-shell.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, type ComponentType, type ReactNode } from "react"
|
||||
import {
|
||||
Bot,
|
||||
BookOpenText,
|
||||
Keyboard,
|
||||
Palette,
|
||||
PlugZap,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
|
||||
interface SettingsNavItem {
|
||||
href: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
{
|
||||
href: "/settings/appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
href: "/settings/agents",
|
||||
label: "Agents",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
href: "/settings/mcp",
|
||||
label: "MCP",
|
||||
icon: PlugZap,
|
||||
},
|
||||
{
|
||||
href: "/settings/skills",
|
||||
label: "Skills",
|
||||
icon: BookOpenText,
|
||||
},
|
||||
{
|
||||
href: "/settings/shortcuts",
|
||||
label: "Shortcuts",
|
||||
icon: Keyboard,
|
||||
},
|
||||
{
|
||||
href: "/settings/system",
|
||||
label: "System",
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const noSuffix = path.replace(/\/index\.html$/, "").replace(/\.html$/, "")
|
||||
const noTrailingSlash = noSuffix.replace(/\/+$/, "")
|
||||
return noTrailingSlash || "/"
|
||||
}
|
||||
|
||||
function isWindowsRuntime(): boolean {
|
||||
if (typeof navigator === "undefined") return false
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return platform.includes("win") || userAgent.includes("windows")
|
||||
}
|
||||
|
||||
export function SettingsShell({ children }: SettingsShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const normalizedPathname = normalizePath(pathname)
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(href: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const target = normalizePath(href)
|
||||
const current = normalizePath(window.location.pathname)
|
||||
if (current === target) return
|
||||
|
||||
if (isWindowsRuntime()) {
|
||||
// WebView2 on Windows: hard navigation is more reliable than client routing.
|
||||
window.location.assign(target)
|
||||
return
|
||||
}
|
||||
|
||||
// macOS/Linux: keep client-side routing for snappier transitions.
|
||||
router.push(target)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-bold tracking-tight">Settings</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<aside className="w-56 shrink-0 border-r p-3">
|
||||
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
||||
Preferences
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{SETTINGS_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active =
|
||||
normalizedPathname === item.href ||
|
||||
normalizedPathname.startsWith(`${item.href}/`)
|
||||
return (
|
||||
<Button
|
||||
key={item.href}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("w-full justify-start")}
|
||||
type="button"
|
||||
onClick={() => navigateTo(item.href)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex-1 min-w-0 min-h-0 p-4">{children}</section>
|
||||
</div>
|
||||
<AppToaster position="bottom-right" closeButton duration={4000} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
src/components/settings/shortcut-settings.tsx
Normal file
156
src/components/settings/shortcut-settings.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Keyboard, RotateCcw } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useIsMac } from "@/hooks/use-is-mac"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
type ShortcutActionId,
|
||||
formatShortcutLabel,
|
||||
shortcutFromKeyboardEvent,
|
||||
} from "@/lib/keyboard-shortcuts"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const SHARED_SHORTCUT_PAIRS: Array<[ShortcutActionId, ShortcutActionId]> = [
|
||||
["new_terminal_tab", "new_conversation"],
|
||||
["close_current_terminal_tab", "close_current_tab"],
|
||||
]
|
||||
|
||||
function canShareShortcut(a: ShortcutActionId, b: ShortcutActionId): boolean {
|
||||
return SHARED_SHORTCUT_PAIRS.some(
|
||||
([left, right]) =>
|
||||
(left === a && right === b) || (left === b && right === a)
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutSettings() {
|
||||
const { shortcuts, updateShortcut, resetShortcuts } = useShortcutSettings()
|
||||
const isMac = useIsMac()
|
||||
const [recordingAction, setRecordingAction] =
|
||||
useState<ShortcutActionId | null>(null)
|
||||
|
||||
const isDefault = useMemo(
|
||||
() =>
|
||||
SHORTCUT_DEFINITIONS.every(
|
||||
(definition) =>
|
||||
shortcuts[definition.id] === DEFAULT_SHORTCUTS[definition.id]
|
||||
),
|
||||
[shortcuts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordingAction) return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setRecordingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
const shortcut = shortcutFromKeyboardEvent(event)
|
||||
if (!shortcut) return
|
||||
|
||||
const conflict = SHORTCUT_DEFINITIONS.find(
|
||||
(definition) =>
|
||||
definition.id !== recordingAction &&
|
||||
!canShareShortcut(definition.id, recordingAction) &&
|
||||
shortcuts[definition.id] === shortcut
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
toast.error(`快捷键已被「${conflict.title}」占用`)
|
||||
return
|
||||
}
|
||||
|
||||
if (updateShortcut(recordingAction, shortcut)) {
|
||||
toast.success("快捷键已更新")
|
||||
} else {
|
||||
toast.error("快捷键无效,请重试")
|
||||
}
|
||||
|
||||
setRecordingAction(null)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
}
|
||||
}, [recordingAction, shortcuts, updateShortcut])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">快捷键</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetShortcuts()
|
||||
setRecordingAction(null)
|
||||
toast.success("已恢复默认快捷键")
|
||||
}}
|
||||
disabled={isDefault}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
点击右侧按钮后按下组合键即可修改。建议使用 Ctrl/Cmd、Alt、Shift
|
||||
的组合。按 Esc 可取消录制。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{SHORTCUT_DEFINITIONS.map((definition) => {
|
||||
const isRecording = recordingAction === definition.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={definition.id}
|
||||
className="rounded-lg border px-3 py-2 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{definition.title}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{definition.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={isRecording ? "default" : "secondary"}
|
||||
size="sm"
|
||||
className="font-mono min-w-36 justify-center"
|
||||
onClick={() => {
|
||||
setRecordingAction((previous) =>
|
||||
previous === definition.id ? null : definition.id
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isRecording
|
||||
? "按下快捷键..."
|
||||
: formatShortcutLabel(shortcuts[definition.id], isMac)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1173
src/components/settings/skills-settings.tsx
Normal file
1173
src/components/settings/skills-settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
306
src/components/settings/system-network-settings.tsx
Normal file
306
src/components/settings/system-network-settings.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { ArrowUpCircle, Loader2, RefreshCw, Save, Wifi } from "lucide-react"
|
||||
import type { Update } from "@tauri-apps/plugin-updater"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getSystemProxySettings, updateSystemProxySettings } from "@/lib/tauri"
|
||||
import {
|
||||
checkAppUpdate,
|
||||
closeAppUpdate,
|
||||
getCurrentAppVersion,
|
||||
installAppUpdate,
|
||||
relaunchApp,
|
||||
} from "@/lib/updater"
|
||||
|
||||
const PROXY_EXAMPLE = "http://127.0.0.1:7890"
|
||||
|
||||
export function SystemNetworkSettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [proxyUrl, setProxyUrl] = useState("")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [currentVersion, setCurrentVersion] = useState<string>("")
|
||||
const [availableUpdate, setAvailableUpdate] = useState<Update | null>(null)
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false)
|
||||
const [installingUpdate, setInstallingUpdate] = useState(false)
|
||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null)
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
try {
|
||||
const settings = await getSystemProxySettings()
|
||||
setEnabled(settings.enabled)
|
||||
setProxyUrl(settings.proxy_url ?? "")
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setLoadError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadAppVersion = useCallback(async () => {
|
||||
try {
|
||||
const version = await getCurrentAppVersion()
|
||||
setCurrentVersion(version)
|
||||
} catch (err) {
|
||||
console.error("[Settings] load app version failed:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings().catch((err) => {
|
||||
console.error("[Settings] load system proxy settings failed:", err)
|
||||
})
|
||||
loadAppVersion().catch((err) => {
|
||||
console.error("[Settings] load app version failed:", err)
|
||||
})
|
||||
}, [loadSettings, loadAppVersion])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!availableUpdate) return
|
||||
closeAppUpdate(availableUpdate).catch((err) => {
|
||||
console.error("[Settings] release updater resource failed:", err)
|
||||
})
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
if (enabled && !proxyUrl.trim()) {
|
||||
toast.error("启用代理时必须填写代理地址")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = await updateSystemProxySettings({
|
||||
enabled,
|
||||
proxy_url: proxyUrl.trim() || null,
|
||||
})
|
||||
setEnabled(next.enabled)
|
||||
setProxyUrl(next.proxy_url ?? "")
|
||||
toast.success("系统代理设置已保存")
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`保存失败:${message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [enabled, proxyUrl])
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setCheckingUpdate(true)
|
||||
setUpdateError(null)
|
||||
|
||||
try {
|
||||
const previousUpdate = availableUpdate
|
||||
const result = await checkAppUpdate()
|
||||
setCurrentVersion(result.currentVersion)
|
||||
setLastCheckedAt(new Date())
|
||||
|
||||
if (result.update) {
|
||||
setAvailableUpdate(result.update)
|
||||
toast.success(`发现新版本 v${result.update.version}`)
|
||||
} else {
|
||||
setAvailableUpdate(null)
|
||||
toast.success("当前已经是最新版本")
|
||||
}
|
||||
|
||||
if (previousUpdate && previousUpdate !== result.update) {
|
||||
await closeAppUpdate(previousUpdate)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setUpdateError(message)
|
||||
toast.error(`检查更新失败:${message}`)
|
||||
} finally {
|
||||
setCheckingUpdate(false)
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!availableUpdate) return
|
||||
|
||||
setInstallingUpdate(true)
|
||||
setUpdateError(null)
|
||||
|
||||
try {
|
||||
await installAppUpdate(availableUpdate)
|
||||
toast.success("升级包已安装,正在重启应用")
|
||||
await relaunchApp()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setUpdateError(message)
|
||||
toast.error(`升级失败:${message}`)
|
||||
} finally {
|
||||
setInstallingUpdate(false)
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="space-y-1">
|
||||
<h1 className="text-sm font-semibold">系统管理</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
管理网络代理和应用版本升级。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">网络代理</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
开启后,后续网络请求将优先走该代理(包括 ACP 对话、Agent 安装、 Git
|
||||
远程操作等)。
|
||||
</p>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
加载失败:{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
启用系统代理
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
代理地址
|
||||
</label>
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(event) => setProxyUrl(event.target.value)}
|
||||
placeholder={PROXY_EXAMPLE}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
支持 http(s)/socks5,示例:{PROXY_EXAMPLE}
|
||||
。仅在启用系统代理时生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={saveSettings} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">应用升级</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
点击检查后会从配置的发布源拉取最新版本信息,有新版本时可直接下载并安装。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">当前版本</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{currentVersion ? `v${currentVersion}` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">可升级版本</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{availableUpdate ? `v${availableUpdate.version}` : "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCheckedAt && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
上次检查:{lastCheckedAt.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
更新异常:{updateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checkingUpdate || installingUpdate}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
检查中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
检查更新
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{availableUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={installUpdate}
|
||||
disabled={installingUpdate || checkingUpdate}
|
||||
>
|
||||
{installingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
升级中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
升级到 v{availableUpdate.version}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user