feat: stage online upgrades before verified restarts

Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice.

Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
This commit is contained in:
whyour
2026-10-17 17:14:15 +08:00
parent a1104ef47c
commit 286088a32c
22 changed files with 2698 additions and 471 deletions
+60
View File
@@ -0,0 +1,60 @@
import { CloudDownloadOutlined } from '@ant-design/icons';
import { Modal, Tooltip } from 'antd';
import { useState } from 'react';
import intl from 'react-intl-universal';
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
import CheckUpdate from '@/pages/setting/checkUpdate';
export default function UpgradeNotice({
collapsed,
systemInfo,
}: {
collapsed: boolean;
systemInfo: any;
}) {
const { job, connected } = useUpgrade();
const [open, setOpen] = useState(false);
const showNotice =
activeStatuses.includes(job.status) ||
job.status === 'ready' ||
job.status === 'failed';
if (!showNotice && !open) return null;
const text = !connected
? intl.get('连接已断开,正在重新连接')
: job.status === 'failed'
? intl.get('更新失败')
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
const label =
job.phase === 'downloading' && job.percent != null
? `${text} ${job.percent}%`
: text;
return (
<>
{showNotice && (
<Tooltip title={label} placement="right">
<button
type="button"
className={`upgrade-notice${
collapsed ? ' upgrade-notice-collapsed' : ''
}`}
onClick={() => setOpen(true)}
aria-label={label}
>
<CloudDownloadOutlined />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
)}
<Modal
open={open}
title={intl.get('更新')}
footer={null}
onCancel={() => setOpen(false)}
width={650}
destroyOnClose
>
<CheckUpdate systemInfo={systemInfo} />
</Modal>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import config from '@/utils/config';
import { notification } from 'antd';
import intl from 'react-intl-universal';
import { useSyncExternalStore } from 'react';
export interface UpgradeJob {
id?: string;
action?: 'download' | 'apply';
status: string;
phase?: string;
artifact?: string;
downloadedBytes?: number;
totalBytes?: number | null;
percent?: number | null;
version?: string;
error?: string;
log?: string;
}
export const activeStatuses = ['starting', 'running', 'restarting'];
export const reloadKey = 'qinglong-upgrade-reload';
export const completionKey = 'qinglong-upgrade-completed';
export const phaseLabels: Record<string, string> = {
downloading: '下载更新中...',
extracting: '正在解压更新包',
verifying: '正在校验更新包',
dependencies: '正在准备依赖',
ready: '更新已就绪',
restarting: '正在重启并更新',
completed: '更新完成',
};
// The sidebar and settings share one poller and one mutation state.
export function createUpgradeStore() {
let snapshot = {
job: { status: 'loading' } as UpgradeJob,
busy: false,
connected: true,
};
const listeners = new Set<() => void>();
let revision = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
const publish = (patch: Partial<typeof snapshot>) => {
snapshot = { ...snapshot, ...patch };
listeners.forEach((listener) => listener());
};
const poll = async () => {
const current = revision;
const attempt = new AbortController();
controller = attempt;
const timeout = setTimeout(() => attempt.abort(), 5000);
try {
const token = localStorage.getItem(config.authKey);
const response = await fetch(`${config.apiPrefix}system/update-status`, {
cache: 'no-store',
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: attempt.signal,
});
if (!response.ok) throw new Error('Upgrade status unavailable');
const result = await response.json();
if (result.code !== 200 || !result.data)
throw new Error('Invalid upgrade status');
if (!listeners.size || current !== revision || attempt.signal.aborted)
return;
publish({ job: result.data, connected: true });
if (
result.data.status === 'completed' &&
result.data.id === sessionStorage.getItem(reloadKey)
) {
sessionStorage.setItem(completionKey, result.data.id);
sessionStorage.removeItem(reloadKey);
window.location.reload();
return;
}
if (
result.data.status === 'completed' &&
result.data.id === sessionStorage.getItem(completionKey)
) {
sessionStorage.removeItem(completionKey);
notification.success({
message: intl.get('升级成功'),
description: intl.get('已重新启动并运行新版本,无需再次重启。'),
duration: 8,
});
}
} catch {
if (listeners.size && current === revision && controller === attempt)
publish({ connected: false });
} finally {
clearTimeout(timeout);
if (listeners.size && controller === attempt)
timer = setTimeout(
poll,
!snapshot.connected || activeStatuses.includes(snapshot.job.status)
? 1000
: 15000,
);
}
};
return {
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) poll();
return () => {
listeners.delete(listener);
if (!listeners.size) {
clearTimeout(timer);
controller?.abort();
revision++;
}
};
},
beginMutation() {
if (snapshot.busy) return false;
revision++;
publish({ busy: true });
return true;
},
finishMutation() {
revision++;
controller?.abort();
controller = undefined;
clearTimeout(timer);
publish({ busy: false });
if (listeners.size) timer = setTimeout(poll, 1000);
},
setJob(job: UpgradeJob) {
publish({ job });
},
};
}
export const upgradeStore = createUpgradeStore();
export default function useUpgrade() {
return useSyncExternalStore(upgradeStore.subscribe, upgradeStore.getSnapshot);
}
+128 -4
View File
@@ -354,20 +354,50 @@ select:-webkit-autofill:focus {
.side-menu-container {
display: flex;
width: 100%;
min-width: 0;
gap: 8px;
justify-content: space-between;
align-items: center;
color: @text-color;
.side-menu-collapse-button:hover {
color: #1890ff;
.side-menu-collapse-button {
flex-shrink: 0;
&:hover {
color: #1890ff;
}
}
.side-menu-user-wrapper {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
}
.side-menu-user-wrapper {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 5px;
.ant-avatar {
flex-shrink: 0;
}
.side-menu-username {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
body[data-mode='phone'] .side-menu-user-wrapper {
max-width: 50vw;
}
.side-menu-user-drop-menu {
position: relative;
text-align: left;
@@ -475,11 +505,105 @@ body[data-mode='phone'] header {
}
body[data-dark='true'] {
.upgrade-notice {
background: #3a3214;
border-color: #6b5415;
color: #ffd666;
}
.ant-popover-arrow-content {
--antd-arrow-background-color: rgb(24, 26, 27);
}
}
.upgrade-notice {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
min-height: 36px;
padding: 7px 10px;
margin: 8px 12px 12px;
border: 1px solid #ffe58f;
border-radius: 6px;
background: #fffbe6;
color: #ad6800;
font: inherit;
font-size: 12px;
line-height: 20px;
text-align: left;
cursor: pointer;
> .anticon {
display: inline-flex;
flex: 0 0 14px;
align-items: center;
justify-content: center;
line-height: 1;
}
> span:not(.anticon) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:focus-visible {
outline: 2px solid #d48806;
outline-offset: 2px;
}
&.upgrade-notice-collapsed {
justify-content: center;
padding: 7px 0;
}
}
.upgrade-panel {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
.upgrade-status-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.upgrade-progress {
display: flex;
flex-direction: column;
gap: 8px;
.ant-progress {
margin: 0;
}
}
.upgrade-progress-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
font-variant-numeric: tabular-nums;
}
.upgrade-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.upgrade-log-link {
height: auto;
padding: 0;
line-height: 22px;
}
}
.ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
+29 -2
View File
@@ -19,6 +19,8 @@ import './index.less';
import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket';
import { observePageLoadTiming } from '../utils/navigationTiming';
import UpgradeNotice from '@/components/UpgradeNotice';
import { completionKey, reloadKey, upgradeStore } from '@/hooks/useUpgrade';
export interface SharedContext {
headerStyle: React.CSSProperties;
@@ -54,6 +56,9 @@ export default function () {
);
const [collapsed, setCollapsed] = useState(false);
const [initLoading, setInitLoading] = useState<boolean>(true);
const observeUpgrade =
Boolean(user.username) &&
!['/login', '/initialization'].includes(location.pathname);
const {
enable: enableDarkMode,
disable: disableDarkMode,
@@ -162,6 +167,18 @@ export default function () {
getHealthStatus();
}, []);
useEffect(() => {
// A reload during restart may initially land on the health error page.
// Keep recovering the accepted job even before the sidebar can mount.
if (
observeUpgrade ||
sessionStorage.getItem(reloadKey) ||
sessionStorage.getItem(completionKey)
) {
return upgradeStore.subscribe(() => {});
}
}, [observeUpgrade]);
useEffect(() => {
reloadSystemConfig();
}, []);
@@ -321,6 +338,12 @@ export default function () {
</>
}
title={false}
menuFooterRender={(props) => (
<UpgradeNotice
collapsed={Boolean(props?.collapsed)}
systemInfo={systemInfo}
/>
)}
menuItemRender={(menuItemProps: any, defaultDom: any) => {
if (
menuItemProps.isUrl ||
@@ -351,7 +374,9 @@ export default function () {
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
</span>
</Dropdown>
)
@@ -377,7 +402,9 @@ export default function () {
: ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
</span>
</Dropdown>
)}
+27 -2
View File
@@ -338,7 +338,7 @@
"显示": "Display",
"智能微秘书": "Smart WeChat Assistant",
"暂无数据": "No data",
"暂无日志": "No logs available",
"暂无日志": "No logs yet",
"暂无脚本": "No scripts available",
"暂无运行中任务": "No running tasks",
"暗色": "Dark",
@@ -731,5 +731,30 @@
"白屏时间": "Time to first response: {duration} ms",
"请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms",
"解释DOM树耗时": "DOM completion time: {duration} ms",
"从开始至load总耗时": "Total load time: {duration} ms"
"从开始至load总耗时": "Total load time: {duration} ms",
"正在解压更新包": "Extracting update",
"正在校验更新包": "Verifying update",
"正在准备依赖": "Preparing dependencies",
"更新已就绪": "Update ready",
"正在重启并更新": "Restarting and updating",
"更新完成": "Update complete",
"更新失败": "Update failed",
"正在准备更新": "Preparing update",
"连接已断开,正在重新连接": "Connection lost. Reconnecting…",
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
"下载构建产物": "Downloading application files",
"下载源码": "Downloading source files",
"正在获取下载大小": "Total size unavailable",
"查看更新日志": "View update log",
"重启并更新": "Restart and update",
"升级任务启动失败,请检查日志后重试": "Failed to start the update. Check the logs and try again.",
"升级任务意外中断,请重试": "The update was interrupted. Please try again.",
"系统将在30秒后自动刷新": "The page will refresh in 30 seconds.",
"运行依赖已变化,请重新下载更新。": "Installed dependencies changed. Download the update again.",
"暂存更新已变化,请重新下载更新。": "The staged update changed. Download the update again.",
"启动版本与更新包不一致": "The running build does not match the update package.",
"下载完成,等待重启": "Download complete, restart to update",
"升级成功": "Update successful",
"已重新启动并运行新版本,无需再次重启。": "Restarted and running the new version. No further restart is needed."
}
+26 -1
View File
@@ -731,5 +731,30 @@
"白屏时间": "白屏时间: {duration} ms",
"请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms",
"解释DOM树耗时": "解释 DOM 树耗时: {duration} ms",
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms"
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
"正在解压更新包": "正在解压更新包",
"正在校验更新包": "正在校验更新包",
"正在准备依赖": "正在准备依赖",
"更新已就绪": "更新已就绪",
"正在重启并更新": "正在重启并更新",
"更新完成": "更新完成",
"更新失败": "更新失败",
"正在准备更新": "正在准备更新",
"连接已断开,正在重新连接": "连接已断开,正在重新连接",
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物",
"下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志",
"重启并更新": "重启并更新",
"升级任务启动失败,请检查日志后重试": "升级任务启动失败,请检查日志后重试",
"升级任务意外中断,请重试": "升级任务意外中断,请重试",
"系统将在30秒后自动刷新": "系统将在30秒后自动刷新",
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
}
+190 -220
View File
@@ -1,249 +1,219 @@
import { disableBody } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import WebSocketManager from '@/utils/websocket';
import Ansi from 'ansi-to-react';
import { Button, Modal, Statistic, message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Modal, Progress, Space, Typography, message } from 'antd';
import { useState } from 'react';
import useUpgrade, {
activeStatuses,
phaseLabels,
reloadKey,
upgradeStore,
} from '@/hooks/useUpgrade';
import intl from 'react-intl-universal';
const { Countdown } = Statistic;
const sizeLabel = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const CheckUpdate = ({ systemInfo }: any) => {
const [updateLoading, setUpdateLoading] = useState(false);
const [value, setValue] = useState('');
const modalRef = useRef<any>();
const lastStatusRef = useRef<string | undefined>();
const [updateStatus, setUpdateStatus] = useState<string>();
const checkUpgrade = () => {
if (updateLoading || modalRef.current) return;
setUpdateLoading(true);
message.loading(intl.get('检查更新中...'), 0);
request
.put(`${config.apiPrefix}system/update-check`)
.then(({ code, data }) => {
message.destroy();
if (code === 200) {
if (data.hasNewVersion) {
showConfirmUpdateModal(data);
} else {
showForceUpdateModal(data);
}
}
})
.catch((error: any) => {
message.destroy();
message.error(intl.get('检查更新失败,请检查网络后重试'));
})
.finally(() => {
setUpdateLoading(false);
});
};
const showForceUpdateModal = (data: any) => {
Modal.confirm({
width: 500,
title: intl.get('更新'),
content: (
<>
<div>{intl.get('已经是最新版了!')}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('青龙')} {data.lastVersion}{' '}
{intl.get('是目前检测到的最新可用版本了。')}
</div>
</>
),
okText: intl.get('重新下载'),
onOk() {
startUpdate();
},
});
};
const showConfirmUpdateModal = (data: any) => {
const { lastVersion, lastLog } = data;
Modal.confirm({
width: 500,
title: (
<>
<div>{intl.get('更新可用')}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('新版本可用,当前版本', {
version: lastVersion,
current: systemInfo.version,
})}
</div>
</>
),
content: (
<pre>
<Ansi>{lastLog}</Ansi>
</pre>
),
okText: intl.get('下载更新'),
cancelText: intl.get('以后再说'),
onOk() {
startUpdate();
},
});
};
const showUpdatingModal = () => {
setValue('');
lastStatusRef.current = 'running';
setUpdateStatus('running');
modalRef.current = Modal.info({
width: 600,
maskClosable: false,
closable: false,
keyboard: false,
okButtonProps: { disabled: true },
title: intl.get('下载更新中...'),
centered: true,
afterClose: () => {
modalRef.current = undefined;
},
content: <pre />,
});
};
const { job, busy, connected } = useUpgrade();
const [details, setDetails] = useState(false);
const active = activeStatuses.includes(job.status);
const ready = job.status === 'ready';
const completed = job.status === 'completed';
const startUpdate = async () => {
showUpdatingModal();
if (busy || active) return;
if (!upgradeStore.beginMutation()) return;
try {
const response = await request.put(`${config.apiPrefix}system/update`);
if (response.code !== 200) throw new Error(response.message);
const result = await request.put(`${config.apiPrefix}system/update`);
if (result.code !== 200) throw new Error(result.message);
upgradeStore.setJob(result.data);
} catch {
lastStatusRef.current = 'failed';
setUpdateStatus('failed');
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
} finally {
upgradeStore.finishMutation();
}
};
const reloadSystem = (type?: string) => {
request
.put(`${config.apiPrefix}update/${type}`)
.then(({ code }: any) => {
if (code !== 200) return;
message.success({
content: (
<span>
{intl.get('系统将在')}
<Countdown
className="inline-countdown"
format="ss"
value={Date.now() + 1000 * 30}
/>
{intl.get('秒后自动刷新')}
</span>
),
duration: 30,
});
disableBody();
setTimeout(() => {
window.location.reload();
}, 30000);
})
.catch((error: any) => {
console.log(error);
const checkUpgrade = async () => {
if (!upgradeStore.beginMutation()) return;
try {
const { code, data } = await request.put(
`${config.apiPrefix}system/update-check`,
);
if (code !== 200) throw new Error('Update check failed');
Modal.confirm({
width: 500,
title: intl.get(data.hasNewVersion ? '更新可用' : '已经是最新版了!'),
content: data.hasNewVersion ? (
<>
<p>
{intl.get('新版本可用,当前版本', {
version: data.lastVersion,
current: systemInfo.version,
})}
</p>
<pre>
<Ansi>{data.lastLog}</Ansi>
</pre>
</>
) : (
`${intl.get('青龙')} ${data.lastVersion}`
),
okText: intl.get(data.hasNewVersion ? '下载更新' : '重新下载'),
cancelText: intl.get('以后再说'),
onOk: startUpdate,
});
} catch {
message.error(intl.get('检查更新失败,请检查网络后重试'));
} finally {
upgradeStore.finishMutation();
}
};
const showReloadModal = () => {
const reloadSystem = async (apply: boolean) => {
if (!upgradeStore.beginMutation()) return;
try {
const result = await request.put(
`${config.apiPrefix}update/${apply ? 'system' : 'reload'}`,
);
if (result.code !== 200) throw new Error(result.message);
if (apply) {
upgradeStore.setJob(result.data);
sessionStorage.setItem(reloadKey, result.data.id);
} else {
message.success(intl.get('系统将在30秒后自动刷新'));
setTimeout(() => window.location.reload(), 30000);
}
} catch {
message.error(intl.get('升级任务启动失败,请检查日志后重试'));
} finally {
upgradeStore.finishMutation();
}
};
const showReloadModal = () =>
Modal.confirm({
width: 600,
maskClosable: false,
title: intl.get('确认重启'),
centered: true,
content: intl.get('系统安装包下载成功,确认重启'),
okText: intl.get('重启'),
onOk() {
reloadSystem('system');
},
onCancel() {
modalRef.current?.update({
maskClosable: true,
closable: true,
okButtonProps: { disabled: false },
});
},
content: intl.get('更新已就绪,重启后应用新版本'),
okText: intl.get('重启并更新'),
cancelText: intl.get('以后再说'),
onOk: () => reloadSystem(true),
});
};
useEffect(() => {
if (!modalRef.current) return;
const updateFailed = updateStatus === 'failed';
modalRef.current.update({
maskClosable: updateFailed,
closable: updateFailed,
okButtonProps: { disabled: !updateFailed },
content: (
<>
<pre>
<Ansi>{value}</Ansi>
</pre>
<div id="log-identifier" style={{ paddingBottom: 5 }}></div>
</>
),
});
}, [value, updateStatus]);
const handleMessage = useCallback((payload: any) => {
if (!modalRef.current || lastStatusRef.current !== 'running') return;
const { message: _message, status } = payload;
if (status === 'failed') {
lastStatusRef.current = 'failed';
setUpdateStatus('failed');
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
}
if (status === 'success') {
lastStatusRef.current = 'success';
setUpdateStatus('success');
setTimeout(() => {
if (modalRef.current && lastStatusRef.current === 'success') {
showReloadModal();
}
}, 1000);
}
setTimeout(() => {
document
.querySelector('#log-identifier')
?.scrollIntoView({ behavior: 'smooth' });
}, 600);
if (_message) {
setValue((p) => `${p}${_message}`);
}
}, []);
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('updateSystemVersion', handleMessage);
return () => {
ws.unsubscribe('updateSystemVersion', handleMessage);
modalRef.current?.destroy();
modalRef.current = undefined;
};
}, []);
return (
<>
<Button type="primary" onClick={checkUpgrade}>
{intl.get('检查更新')}
</Button>
const label =
job.status === 'failed'
? intl.get('更新失败')
: ready
? intl.get('下载完成,等待重启')
: completed
? intl.get('升级成功')
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
const hasJob = job.status !== 'idle' && job.status !== 'loading';
const actions = !active && (
<Space wrap>
<Button
type="primary"
onClick={() => reloadSystem('reload')}
style={{ marginLeft: 8 }}
loading={busy}
disabled={!connected || job.status === 'loading'}
onClick={ready ? showReloadModal : checkUpgrade}
>
{intl.get('重新启动')}
{intl.get(ready ? '重启并更新' : '检查更新')}
</Button>
</>
{!ready && !completed && (
<Button
disabled={busy || !connected || job.status === 'loading'}
onClick={() => reloadSystem(false)}
>
{intl.get('重新启动')}
</Button>
)}
</Space>
);
return (
<div className="upgrade-panel">
{!connected && (
<Typography.Text type="warning" role="status">
{intl.get('连接已断开,正在重新连接')}
</Typography.Text>
)}
{!hasJob && actions}
{hasJob && (
<div
className="upgrade-status-content"
role="status"
aria-live="polite"
>
<Typography.Text strong>
{label}
{job.version ? ` · v${job.version}` : ''}
</Typography.Text>
<Typography.Text
type={job.status === 'failed' ? 'danger' : 'secondary'}
>
{job.status === 'failed'
? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') ||
job.error
: intl.get(
completed
? '已重新启动并运行新版本,无需再次重启。'
: ready
? '更新已就绪,重启后应用新版本'
: '更新在后台进行,可以继续使用面板',
)}
</Typography.Text>
{job.phase === 'downloading' && active && (
<div className="upgrade-progress">
<div className="upgrade-progress-meta">
<Typography.Text type="secondary">
{intl.get(
job.artifact === 'qinglong-static'
? '下载构建产物'
: '下载源码',
)}
</Typography.Text>
<Typography.Text type="secondary">
{sizeLabel(job.downloadedBytes || 0)}
{job.totalBytes ? ` / ${sizeLabel(job.totalBytes)}` : ''}
</Typography.Text>
</div>
<Progress
percent={job.percent ?? 0}
showInfo={job.percent != null}
status="active"
/>
</div>
)}
<div className="upgrade-actions">
{actions}
<Button
type="link"
className="upgrade-log-link"
onClick={() => setDetails(true)}
>
{intl.get('查看更新日志')}
</Button>
</div>
</div>
)}
<Modal
open={details}
title={intl.get('更新日志')}
footer={null}
onCancel={() => setDetails(false)}
width={700}
>
<pre
style={{
maxHeight: '60vh',
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
<Ansi>{job.log || intl.get('暂无日志')}</Ansi>
</pre>
</Modal>
</div>
);
};