Files
OpenFlare/atsf_server/web/components/layout/dashboard-topbar.tsx
T
ryan 93e43fb3b0 feat: add manual server binary upload and confirmation upgrade process
- Implemented UploadManualServerBinary endpoint for uploading server binaries and checking their versions.
- Added ConfirmManualServerUpgrade endpoint to confirm the upgrade with the uploaded binary.
- Updated API routes to include manual upload and upgrade confirmation.
- Enhanced service layer to handle manual binary uploads, version detection, and upgrade execution.
- Introduced new types for handling uploaded binary information.
- Updated frontend components to support manual binary upload and confirmation, including UI feedback for users.
- Modified documentation to reflect new manual upload and upgrade features.
2026-03-12 10:34:31 +08:00

310 lines
11 KiB
TypeScript

'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { 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 {
confirmManualServerUpgrade,
getLatestRelease,
upgradeServer,
uploadServerBinary,
} from '@/features/update/api/update';
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
import type { 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 [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 menuRef = useRef<HTMLDivElement | null>(null);
const isRoot = (user?.role ?? 0) >= 100;
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const latestReleaseQuery = useQuery({
queryKey: ['update', 'latest-release'],
queryFn: getLatestRelease,
enabled: isRoot,
refetchInterval: 60 * 60 * 1000,
});
const upgradeMutation = useMutation({
mutationFn: upgradeServer,
onSuccess: (release) => {
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setVersionFeedback(
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
);
void latestReleaseQuery.refetch();
},
onError: (error) => {
setVersionFeedback(
error instanceof Error ? error.message : '升级失败,请稍后重试。',
);
},
});
const uploadBinaryMutation = useMutation({
mutationFn: uploadServerBinary,
onSuccess: (candidate) => {
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(candidate.comparison_message);
},
onError: (error) => {
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
);
},
});
const confirmManualUpgradeMutation = useMutation({
mutationFn: confirmManualServerUpgrade,
onSuccess: (candidate) => {
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
);
void latestReleaseQuery.refetch();
},
onError: (error) => {
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error
? error.message
: '确认手动升级失败,请稍后重试。',
);
},
});
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 = () => {
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setIsVersionModalOpen(true);
if (isRoot) {
void latestReleaseQuery.refetch();
}
};
const handleUpgrade = () => {
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
upgradeMutation.mutate();
};
const handleUploadBinary = (binary: File) => {
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 release = latestReleaseQuery.data;
const hasUpdate = Boolean(isRoot && release?.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 ||
(latestReleaseQuery.isError
? latestReleaseQuery.error instanceof Error
? latestReleaseQuery.error.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}
frontendVersion={publicEnv.appVersion}
startTime={publicStatusQuery.data?.start_time}
release={release}
uploadedBinary={uploadedBinary}
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
releaseErrorMessage={versionErrorMessage}
manualStatusMessage={manualUpgradeStatus ?? undefined}
manualErrorMessage={manualUpgradeErrorMessage}
canUpgrade={isRoot}
isChecking={latestReleaseQuery.isFetching}
isUpgrading={upgradeMutation.isPending}
isUploadingBinary={uploadBinaryMutation.isPending}
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
onRefresh={() => {
setVersionFeedback(null);
if (isRoot) {
void latestReleaseQuery.refetch();
}
}}
onUpgrade={handleUpgrade}
onUploadBinary={handleUploadBinary}
onConfirmManualUpgrade={handleConfirmManualUpgrade}
/>
</>
);
}