mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
feat: implement server self-upgrade functionality
- Added update service to fetch the latest server release from GitHub. - Implemented server upgrade scheduling and execution logic. - Created platform-specific restart mechanisms for Unix and Windows. - Introduced API endpoints for fetching the latest release and triggering upgrades. - Developed UI components for version upgrade modal, including loading and error states. - Updated documentation to reflect new upgrade features and instructions. - Refactored settings types to streamline interface definitions.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
'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 { getLatestRelease, upgradeServer } from '@/features/update/api/update';
|
||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
|
||||
@@ -12,11 +16,45 @@ 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 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 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) => {
|
||||
setVersionFeedback(
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
|
||||
);
|
||||
void latestReleaseQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setVersionFeedback(
|
||||
error instanceof Error ? error.message : '升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) {
|
||||
@@ -60,60 +98,127 @@ export function DashboardTopbar() {
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
const handleOpenVersionModal = () => {
|
||||
setVersionFeedback(null);
|
||||
setIsVersionModalOpen(true);
|
||||
if (isRoot) {
|
||||
void latestReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
setVersionFeedback(null);
|
||||
upgradeMutation.mutate();
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
<>
|
||||
<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)]'>
|
||||
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 sm:inline-flex'>
|
||||
版本 {publicEnv.appVersion}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<div className='relative' ref={menuRef}>
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<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'
|
||||
type="button"
|
||||
onClick={handleOpenVersionModal}
|
||||
className={[
|
||||
'inline-flex rounded-full border px-3 py-1.5 transition',
|
||||
versionButtonClassName,
|
||||
].join(' ')}
|
||||
>
|
||||
<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>
|
||||
<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 right-0 top-[calc(100%+0.5rem)] 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}
|
||||
{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>
|
||||
<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}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<VersionUpgradeModal
|
||||
isOpen={isVersionModalOpen}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
currentVersion={currentVersion}
|
||||
frontendVersion={publicEnv.appVersion}
|
||||
startTime={publicStatusQuery.data?.start_time}
|
||||
release={release}
|
||||
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
|
||||
errorMessage={versionErrorMessage}
|
||||
canUpgrade={isRoot}
|
||||
isChecking={latestReleaseQuery.isFetching}
|
||||
isUpgrading={upgradeMutation.isPending}
|
||||
onRefresh={() => {
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
void latestReleaseQuery.refetch();
|
||||
}
|
||||
}}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,9 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
<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-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>欢迎使用 ATSFlare 管理端</h1>
|
||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
当前已接入认证入口、核心业务模块以及阶段 4 的设置、用户、文件与关于页迁移。
|
||||
</p>
|
||||
<p className='text-3xl font-medium text-[var(--brand-primary)]'>ATSFlare</p>
|
||||
</div>
|
||||
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
||||
<ThemeToggle />
|
||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||
{publicEnv.appVersion}
|
||||
</span>
|
||||
@@ -33,10 +28,10 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
<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>
|
||||
|
||||
@@ -94,9 +94,6 @@ export function AboutPage() {
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap gap-3 text-sm'>
|
||||
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
返回新版总览
|
||||
</Link>
|
||||
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
前往登录
|
||||
</Link>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title='用户登录' description='使用现有 ATSFlare 账户登录新版管理端。'>
|
||||
<AppCard title='用户登录'>
|
||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||
<AuthFormField label='用户名'>
|
||||
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
|
||||
@@ -115,11 +115,11 @@ export function LoginForm() {
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
忘记密码?点击重置
|
||||
忘记密码?
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||
没有账户?点击注册
|
||||
注册
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,48 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
LatestReleaseInfo,
|
||||
OptionItem,
|
||||
SettingsProfile,
|
||||
UpdateSelfPayload,
|
||||
} from '@/features/settings/types';
|
||||
|
||||
export function getOptions() {
|
||||
return apiRequest<OptionItem[]>('/option/');
|
||||
}
|
||||
|
||||
export function updateOption(key: string, value: string) {
|
||||
return apiRequest<void>('/option/', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
export function rotateBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getLatestRelease() {
|
||||
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
||||
}
|
||||
|
||||
export function getSettingsProfile() {
|
||||
return apiRequest<SettingsProfile>('/user/self');
|
||||
}
|
||||
|
||||
export function updateSelf(payload: UpdateSelfPayload) {
|
||||
return apiRequest<void>('/user/self', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function generateAccessToken() {
|
||||
return apiRequest<string>('/user/token');
|
||||
}
|
||||
|
||||
export function bindWeChat(code: string) {
|
||||
return apiRequest<void>(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export function bindEmail(email: string, code: string) {
|
||||
const searchParams = new URLSearchParams({ email, code });
|
||||
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function getAboutContent() {
|
||||
return apiRequest<string>('/about');
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
OptionItem,
|
||||
SettingsProfile,
|
||||
UpdateSelfPayload,
|
||||
} from '@/features/settings/types';
|
||||
|
||||
export function getOptions() {
|
||||
return apiRequest<OptionItem[]>('/option/');
|
||||
}
|
||||
|
||||
export function updateOption(key: string, value: string) {
|
||||
return apiRequest<void>('/option/', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettingsProfile() {
|
||||
return apiRequest<SettingsProfile>('/user/self');
|
||||
}
|
||||
|
||||
export function updateSelf(payload: UpdateSelfPayload) {
|
||||
return apiRequest<void>('/user/self', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function generateAccessToken() {
|
||||
return apiRequest<string>('/user/token');
|
||||
}
|
||||
|
||||
export function bindWeChat(code: string) {
|
||||
return apiRequest<void>(
|
||||
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindEmail(email: string, code: string) {
|
||||
const searchParams = new URLSearchParams({ email, code });
|
||||
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function getAboutContent() {
|
||||
return apiRequest<string>('/about');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,14 @@
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
export interface OptionItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface BootstrapTokenPayload {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface UpdateSelfPayload {
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SettingsProfile = AuthUser;
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
export interface OptionItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UpdateSelfPayload {
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SettingsProfile = AuthUser;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type { LatestReleaseInfo } from '@/features/update/types';
|
||||
|
||||
export function getLatestRelease() {
|
||||
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
||||
}
|
||||
|
||||
export function upgradeServer() {
|
||||
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type { LatestReleaseInfo } from '@/features/update/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
interface VersionUpgradeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
frontendVersion: string;
|
||||
startTime?: number;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
canUpgrade: boolean;
|
||||
isChecking: boolean;
|
||||
isUpgrading: boolean;
|
||||
onRefresh: () => void;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
return { label: '未检查', variant: 'info' as const };
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return { label: '升级中', variant: 'warning' as const };
|
||||
}
|
||||
if (release.has_update) {
|
||||
return { label: '可升级', variant: 'warning' as const };
|
||||
}
|
||||
return { label: '最新', variant: 'success' as const };
|
||||
}
|
||||
|
||||
export function VersionUpgradeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentVersion,
|
||||
frontendVersion,
|
||||
startTime,
|
||||
release,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
canUpgrade,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
onRefresh,
|
||||
onUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="在这里检查 GitHub 最新版本,并直接触发 Server 自升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking || isUpgrading}
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查更新'}
|
||||
</SecondaryButton>
|
||||
{canUpgrade ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
disabled={
|
||||
!release?.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported
|
||||
}
|
||||
>
|
||||
{isUpgrading
|
||||
? '升级中...'
|
||||
: release?.in_progress
|
||||
? '升级中...'
|
||||
: '立即升级'}
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AppCard title="前端版本">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{frontendVersion}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="服务端版本">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{currentVersion || 'unknown'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={upgradeBadge.label}
|
||||
variant={upgradeBadge.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="最新版本">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="启动时间">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{startTime ? formatDateTime(new Date(startTime * 1000)) : '未知'}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{!isLoading && errorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={errorMessage} />
|
||||
) : null}
|
||||
{!isLoading && !errorMessage && !release ? (
|
||||
<EmptyState
|
||||
title="尚未检查更新"
|
||||
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !errorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub Release · ${release.tag_name}`}
|
||||
description={
|
||||
release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '未提供发布时间'
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
variant={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{!release.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前平台不支持自动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
{release.in_progress ? (
|
||||
<StatusBadge label="升级任务执行中" variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(
|
||||
release.body || '暂无更新说明',
|
||||
) as string,
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
查看发布详情
|
||||
</a>
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface LatestReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
upgrade_supported: boolean;
|
||||
in_progress: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user