[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
interface DashboardShellProps {
children: ReactNode;
}
export function DashboardShell({ children }: DashboardShellProps) {
return (
<div className="flex min-h-screen bg-transparent text-[var(--foreground-primary)]">
<DashboardSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<DashboardTopbar />
<main className="flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,295 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ShieldCheck } from 'lucide-react';
import { dashboardNavigation } from '@/lib/constants/navigation';
import { cn } from '@/lib/utils/cn';
import { isNavigationItemActive } from '@/lib/utils/navigation';
import { useAppShellStore } from '@/store/app-shell';
import type { NavigationIconKey, NavigationItem } from '@/types/navigation';
function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
const commonProps = {
className: 'h-[18px] w-[18px]',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
viewBox: '0 0 24 24',
};
switch (icon) {
case 'home':
return (
<svg {...commonProps}>
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5.5 9.5V21h13V9.5" />
<path d="M9.5 21v-6h5v6" />
</svg>
);
case 'node':
return (
<svg {...commonProps}>
<rect x="4" y="4" width="6" height="6" rx="1.5" />
<rect x="14" y="4" width="6" height="6" rx="1.5" />
<rect x="9" y="14" width="6" height="6" rx="1.5" />
<path d="M10 7h4M12 10v4" />
</svg>
);
case 'website':
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17" />
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
</svg>
);
case 'origin':
return (
<svg {...commonProps}>
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
<circle cx="12" cy="10.1" r="1.9" />
</svg>
);
case 'domain':
return (
<svg {...commonProps}>
<path d="M4 8.5h16" />
<path d="M4 15.5h16" />
<rect x="3.5" y="5" width="17" height="14" rx="3" />
</svg>
);
case 'certificate':
return (
<svg {...commonProps}>
<path d="M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z" />
<path d="M9 10h6" />
<path d="M10 14h4" />
</svg>
);
case 'proxy':
return (
<svg {...commonProps}>
<path d="M7 7h10" />
<path d="M7 12h10" />
<path d="M7 17h6" />
<path d="m14 15 3 2-3 2" />
</svg>
);
case 'pages':
return (
<svg {...commonProps}>
<path d="M5 5.5h14v13H5z" />
<path d="M8 9h8" />
<path d="M8 12h5" />
<path d="M8 15h7" />
</svg>
);
case 'waf':
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
case 'release':
return (
<svg {...commonProps}>
<path d="M12 4v10" />
<path d="m8.5 7.5 3.5-3.5 3.5 3.5" />
<path d="M5 19.5h14" />
</svg>
);
case 'log':
return (
<svg {...commonProps}>
<rect x="5" y="4" width="14" height="16" rx="2.5" />
<path d="M8 8h8" />
<path d="M8 12h8" />
<path d="M8 16h5" />
</svg>
);
case 'performance':
return (
<svg {...commonProps}>
<path d="M4.5 19.5h15" />
<path d="M7 16l3-4 3 2 4-6" />
<path d="m15.5 8 1.5 0 0 1.5" />
</svg>
);
case 'user':
return (
<svg {...commonProps}>
<circle cx="12" cy="8" r="3.25" />
<path d="M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5" />
</svg>
);
case 'setting':
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3.5" />
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9" />
</svg>
);
}
}
function SidebarNavItem({
item,
currentPath,
isSidebarCollapsed,
forceExpanded,
onNavigate,
depth = 0,
}: {
item: NavigationItem;
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
depth?: number;
}) {
const active = isNavigationItemActive(currentPath, item);
const hasChildren = Boolean(item.children?.length);
const showLabel = forceExpanded || !isSidebarCollapsed;
return (
<div className="space-y-2">
<Link
href={item.href}
onClick={onNavigate}
className={cn(
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
depth > 0 && 'ml-3 rounded-xl',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]">
<SidebarIcon icon={item.icon} />
</span>
{showLabel ? (
<span className="min-w-0 flex-1 text-sm font-medium">
{item.label}
</span>
) : null}
</Link>
{showLabel && hasChildren ? (
<div className="space-y-2">
{item.children?.map((child) => (
<SidebarNavItem
key={child.href}
item={child}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</div>
) : null}
</div>
);
}
function SidebarContent({
currentPath,
isSidebarCollapsed,
forceExpanded = false,
onNavigate,
}: {
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
}) {
const showLabel = forceExpanded || !isSidebarCollapsed;
return (
<div className="flex h-full flex-col gap-5">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]">
AF
</div>
{showLabel ? (
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
OpenFlare
</p>
</div>
) : null}
</div>
<nav className="no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
);
}
export function DashboardSidebar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore(
(state) => state.isSidebarCollapsed,
);
const isMobileSidebarOpen = useAppShellStore(
(state) => state.isMobileSidebarOpen,
);
const setMobileSidebarOpen = useAppShellStore(
(state) => state.setMobileSidebarOpen,
);
useEffect(() => {
setMobileSidebarOpen(false);
}, [currentPath, setMobileSidebarOpen]);
return (
<>
<div
className={cn(
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
onClick={() => setMobileSidebarOpen(false)}
aria-hidden="true"
/>
<aside
className={cn(
'fixed top-0 left-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={false}
forceExpanded
onNavigate={() => setMobileSidebarOpen(false)}
/>
</aside>
<aside
className={cn(
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
/>
</aside>
</>
);
}
@@ -0,0 +1,499 @@
'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/providers/auth-provider';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getPublicStatus } from '@/features/auth/api/public';
import {
createUpgradeLogsWebSocket,
confirmManualServerUpgrade,
getLatestRelease,
parseUpgradeStreamSnapshot,
upgradeServer,
uploadServerBinary,
} from '@/features/update/api/update';
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
import type {
LatestReleaseInfo,
ReleaseChannel,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
} from '@/features/update/types';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
export function DashboardTopbar() {
const router = useRouter();
const { logout, user } = useAuth();
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
const isMobileSidebarOpen = useAppShellStore(
(state) => state.isMobileSidebarOpen,
);
const setMobileSidebarOpen = useAppShellStore(
(state) => state.setMobileSidebarOpen,
);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [selectedReleaseChannel, setSelectedReleaseChannel] =
useState<ReleaseChannel>('stable');
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
null,
);
const [manualUpgradeError, setManualUpgradeError] = useState<string | null>(
null,
);
const [uploadedBinary, setUploadedBinary] =
useState<UploadedServerBinaryInfo | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [upgradeStream, setUpgradeStream] =
useState<UpgradeStreamSnapshot | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const upgradeRefreshPendingRef = useRef(false);
const upgradeReloadStartedRef = useRef(false);
const upgradeReloadTimerRef = useRef<number | null>(null);
const isRoot = (user?.role ?? 0) >= 100;
const upgradeStatusPollInterval = 3000;
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const stableReleaseQuery = useQuery({
queryKey: ['update', 'latest-release', 'stable'],
queryFn: () => getLatestRelease('stable'),
enabled: isRoot,
refetchInterval: (query) => {
const release = query.state.data;
if (isVersionModalOpen && release?.in_progress) {
return upgradeStatusPollInterval;
}
return 60 * 60 * 1000;
},
});
const previewReleaseQuery = useQuery({
queryKey: ['update', 'latest-release', 'preview'],
queryFn: () => getLatestRelease('preview'),
enabled: false,
refetchInterval: (query) => {
const release = query.state.data;
if (isVersionModalOpen && release?.in_progress) {
return upgradeStatusPollInterval;
}
return false;
},
});
const upgradeMutation = useMutation({
mutationFn: (channel: ReleaseChannel) => upgradeServer(channel),
onSuccess: (release) => {
upgradeRefreshPendingRef.current = true;
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setVersionFeedback(
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
);
void stableReleaseQuery.refetch();
if (release.channel === 'preview') {
void previewReleaseQuery.refetch();
}
},
onError: (error) => {
setVersionFeedback(
error instanceof Error ? error.message : '升级失败,请稍后重试。',
);
},
});
const uploadBinaryMutation = useMutation({
mutationFn: (binary: File) =>
uploadServerBinary(binary, (progress) => {
setUploadProgress(progress);
}),
onSuccess: (candidate) => {
setUploadProgress(0);
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(candidate.comparison_message);
},
onError: (error) => {
setUploadProgress(0);
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
);
},
});
const confirmManualUpgradeMutation = useMutation({
mutationFn: confirmManualServerUpgrade,
onSuccess: (candidate) => {
upgradeRefreshPendingRef.current = true;
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
);
void stableReleaseQuery.refetch();
void previewReleaseQuery.refetch();
},
onError: (error) => {
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error
? error.message
: '确认手动升级失败,请稍后重试。',
);
},
});
const scheduleUpgradePageReload = useCallback(() => {
if (upgradeReloadStartedRef.current) {
return;
}
upgradeReloadStartedRef.current = true;
setVersionFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
const reloadWhenServerReady = async () => {
try {
await getPublicStatus();
window.location.reload();
} catch {
upgradeReloadTimerRef.current = window.setTimeout(
reloadWhenServerReady,
1500,
);
}
};
upgradeReloadTimerRef.current = window.setTimeout(
reloadWhenServerReady,
1200,
);
}, []);
useEffect(() => {
return () => {
if (upgradeReloadTimerRef.current !== null) {
window.clearTimeout(upgradeReloadTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!isVersionModalOpen || !isRoot) {
setUpgradeStream(null);
return;
}
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const connect = () => {
if (closed) {
return;
}
socket = createUpgradeLogsWebSocket();
if (!socket) {
return;
}
socket.onmessage = (event) => {
const snapshot = parseUpgradeStreamSnapshot(String(event.data));
if (snapshot) {
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
upgradeRefreshPendingRef.current = true;
}
if (snapshot.upgrade_status === 'failed') {
upgradeRefreshPendingRef.current = false;
}
setUpgradeStream(snapshot);
}
};
socket.onclose = () => {
if (closed) {
return;
}
if (upgradeRefreshPendingRef.current) {
scheduleUpgradePageReload();
return;
}
reconnectTimer = window.setTimeout(connect, 1500);
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
socket?.close();
};
}, [isRoot, isVersionModalOpen, scheduleUpgradePageReload]);
useEffect(() => {
if (!isUserMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setIsUserMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsUserMenuOpen(false);
}
};
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('keydown', handleEscape);
};
}, [isUserMenuOpen]);
const handleLogout = async () => {
setIsLoggingOut(true);
setIsUserMenuOpen(false);
await logout();
router.replace('/login');
};
const handleSidebarToggle = () => {
if (window.innerWidth < 1000) {
setMobileSidebarOpen(!isMobileSidebarOpen);
return;
}
toggleSidebar();
};
const handleOpenVersionModal = () => {
setSelectedReleaseChannel('stable');
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setIsVersionModalOpen(true);
if (isRoot) {
void stableReleaseQuery.refetch();
}
};
const handleUpgrade = () => {
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
upgradeMutation.mutate(selectedReleaseChannel);
};
const handleCheckRelease = () => {
setVersionFeedback(null);
if (isRoot) {
if (selectedReleaseChannel === 'preview') {
void previewReleaseQuery.refetch();
} else {
void stableReleaseQuery.refetch();
}
}
};
const handleReleaseChannelChange = (channel: ReleaseChannel) => {
setSelectedReleaseChannel(channel);
setVersionFeedback(null);
};
const handleUploadBinary = (binary: File) => {
setUploadProgress(0);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
uploadBinaryMutation.mutate(binary);
};
const handleConfirmManualUpgrade = () => {
if (!uploadedBinary?.upload_token) {
setManualUpgradeStatus(null);
setManualUpgradeError('请先上传并检查升级包。');
return;
}
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
};
const selectedRelease =
selectedReleaseChannel === 'preview'
? previewReleaseQuery.data
: stableReleaseQuery.data;
const releaseWithStream = mergeReleaseWithUpgradeStream(
selectedRelease,
upgradeStream,
);
const selectedReleaseError =
selectedReleaseChannel === 'preview'
? previewReleaseQuery.error
: stableReleaseQuery.error;
const isSelectedReleaseError =
selectedReleaseChannel === 'preview'
? previewReleaseQuery.isError
: stableReleaseQuery.isError;
const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update);
const currentVersion = publicStatusQuery.data?.version || 'unknown';
const versionLabel = hasUpdate
? `版本 ${publicEnv.appVersion} · 可升级`
: `版本 ${publicEnv.appVersion}`;
const versionButtonClassName = hasUpdate
? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]'
: 'border-[var(--border-default)]';
const versionErrorMessage =
versionFeedback ||
(isSelectedReleaseError
? selectedReleaseError instanceof Error
? selectedReleaseError.message
: '版本检查失败,请稍后重试。'
: undefined);
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
return (
<>
<header className="sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={handleSidebarToggle}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
aria-label="切换侧边栏"
>
☰
</button>
<div className="flex items-center gap-3 text-sm text-[var(--foreground-secondary)]">
<button
type="button"
onClick={handleOpenVersionModal}
className={[
'inline-flex rounded-full border px-3 py-1.5 transition',
versionButtonClassName,
].join(' ')}
>
<span className="sm:hidden">版本</span>
<span className="hidden sm:inline">{versionLabel}</span>
</button>
<ThemeToggle />
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setIsUserMenuOpen((value) => !value)}
className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
aria-expanded={isUserMenuOpen}
aria-haspopup="menu"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold">
{(user?.display_name || user?.username || 'U')
.slice(0, 1)
.toUpperCase()}
</span>
<span className="hidden sm:inline">
{user?.display_name || user?.username || '用户'}
</span>
</button>
{isUserMenuOpen ? (
<div className="absolute top-[calc(100%+0.5rem)] right-0 w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]">
<div className="rounded-xl px-3 py-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{user?.display_name || user?.username || '用户'}
</p>
{user?.username ? (
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
@{user.username}
</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className="flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
<VersionUpgradeModal
isOpen={isVersionModalOpen}
onClose={() => setIsVersionModalOpen(false)}
currentVersion={currentVersion}
release={releaseWithStream}
selectedChannel={selectedReleaseChannel}
uploadedBinary={uploadedBinary}
isLoading={
(selectedReleaseChannel === 'preview'
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) &&
isRoot
}
releaseErrorMessage={versionErrorMessage}
manualStatusMessage={manualUpgradeStatus ?? undefined}
manualErrorMessage={manualUpgradeErrorMessage}
canUpgrade={isRoot}
isChecking={
selectedReleaseChannel === 'preview'
? previewReleaseQuery.isFetching
: stableReleaseQuery.isFetching
}
isUpgrading={upgradeMutation.isPending}
isUploadingBinary={uploadBinaryMutation.isPending}
uploadProgress={uploadProgress}
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
onChannelChange={handleReleaseChannelChange}
onCheck={handleCheckRelease}
onUpgrade={handleUpgrade}
onUploadBinary={handleUploadBinary}
onConfirmManualUpgrade={handleConfirmManualUpgrade}
/>
</>
);
}
function mergeReleaseWithUpgradeStream(
release: LatestReleaseInfo | null | undefined,
stream: UpgradeStreamSnapshot | null,
) {
if (!release || !stream) {
return release;
}
return {
...release,
in_progress: stream.in_progress,
upgrade_status: stream.upgrade_status,
upgrade_logs: stream.upgrade_logs,
};
}
@@ -0,0 +1,30 @@
import type {ReactNode} from 'react';
interface PageHeaderProps {
title: string;
description?: string;
action?: ReactNode;
}
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<p className="text-sm font-medium tracking-[0.24em] text-[var(--brand-primary)] uppercase">
OpenFlare
</p>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
{description}
</p>
) : null}
</div>
</div>
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
</div>
);
}
@@ -0,0 +1,48 @@
import Link from 'next/link';
import type {ReactNode} from 'react';
import {publicEnv} from '@/lib/env/public-env';
interface PublicShellProps {
children: ReactNode;
}
export function PublicShell({ children }: PublicShellProps) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12">
<div className="w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur">
<div className="mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-3xl font-medium text-[var(--brand-primary)]">
OpenFlare
</p>
</div>
<div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
{publicEnv.appVersion}
</span>
</div>
</div>
<div className="space-y-6">{children}</div>
<div className="mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]">
<div className="flex flex-wrap gap-4">
<Link
href="/"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
返回
</Link>
<Link
href="/about"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
关于
</Link>
</div>
</div>
</div>
</div>
);
}