feat: 更新安装显示进度条+完成对话框, 源码部署SSE流式进度
This commit is contained in:
1 parent
82014ae64c
commit
49fadde800
5 files changed
+239
-26
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* POST /api/update-source-stream
|
||||
* SSE 流式更新源码:git pull → npm install → npm run build
|
||||
* 实时推送每个步骤的进度
|
||||
*/
|
||||
export async function POST() {
|
||||
const cwd = process.cwd();
|
||||
const gitDir = join(cwd, '.git');
|
||||
|
||||
if (!existsSync(gitDir)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '非源码部署环境,无法执行自动更新' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ step: 1, total: 3, label: '🔄 拉取最新代码', cmd: 'git', args: ['pull'], timeout: 60000 },
|
||||
{ step: 2, total: 3, label: '📦 安装依赖', cmd: 'npm', args: ['install'], timeout: 300000 },
|
||||
{ step: 3, total: 3, label: '🔨 构建项目', cmd: 'npm', args: ['run', 'build'], timeout: 300000 },
|
||||
];
|
||||
|
||||
try {
|
||||
for (const stepInfo of steps) {
|
||||
send({ step: stepInfo.step, total: stepInfo.total, label: stepInfo.label, status: 'running' });
|
||||
|
||||
const result = await runCommand(stepInfo.cmd, stepInfo.args, cwd, stepInfo.timeout);
|
||||
|
||||
if (!result.success) {
|
||||
send({
|
||||
step: stepInfo.step, total: stepInfo.total, label: stepInfo.label,
|
||||
status: 'error', log: result.output,
|
||||
});
|
||||
send({ done: true, success: false, error: `步骤 ${stepInfo.step} 失败: ${stepInfo.label}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 git pull 是否已是最新
|
||||
if (stepInfo.step === 1 && (result.output.includes('Already up to date') || result.output.includes('已经是最新'))) {
|
||||
send({
|
||||
step: stepInfo.step, total: stepInfo.total, label: stepInfo.label,
|
||||
status: 'done', log: result.output,
|
||||
});
|
||||
send({ done: true, success: true, alreadyUpToDate: true });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
step: stepInfo.step, total: stepInfo.total, label: stepInfo.label,
|
||||
status: 'done', log: result.lastLines,
|
||||
});
|
||||
}
|
||||
|
||||
send({ done: true, success: true, alreadyUpToDate: false });
|
||||
} catch (err) {
|
||||
send({ done: true, success: false, error: err.message });
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runCommand(cmd, args, cwd, timeout) {
|
||||
return new Promise((resolve) => {
|
||||
let output = '';
|
||||
let timer;
|
||||
|
||||
// Windows 下用 shell 模式
|
||||
const proc = spawn(cmd, args, {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
proc.stdout.on('data', (data) => { output += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { output += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
const lines = output.trim().split('\n');
|
||||
resolve({
|
||||
success: code === 0,
|
||||
output: output.trim(),
|
||||
lastLines: lines.slice(-5).join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ success: false, output: err.message, lastLines: err.message });
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
resolve({ success: false, output: '命令执行超时', lastLines: '命令执行超时' });
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
+61
-11
@@ -477,6 +477,7 @@ export default function HelpPanel({ open, onClose }) {
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(null); // { progress, downloaded, total }
|
||||
const [updateDone, setUpdateDone] = useState(null); // { success, message, logs }
|
||||
const [sourceProgress, setSourceProgress] = useState(null); // { step, total, label, status }
|
||||
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectron;
|
||||
|
||||
@@ -528,24 +529,49 @@ export default function HelpPanel({ open, onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
// 源码部署:git pull + build
|
||||
// 源码部署:SSE 流式更新
|
||||
const handleSourceUpdate = async () => {
|
||||
setUpdating(true);
|
||||
setUpdateDone(null);
|
||||
setSourceProgress(null);
|
||||
try {
|
||||
const res = await fetch('/api/update-source', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (data.alreadyUpToDate) {
|
||||
setUpdateDone({ success: true, message: t('update.alreadyLatest'), logs: data.logs });
|
||||
} else {
|
||||
setUpdateDone({ success: true, message: t('update.updateSuccess'), logs: data.logs });
|
||||
const res = await fetch('/api/update-source-stream', { method: 'POST' });
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const block of lines) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const data = JSON.parse(dataLine.slice(6));
|
||||
|
||||
if (data.done) {
|
||||
if (data.success) {
|
||||
if (data.alreadyUpToDate) {
|
||||
setUpdateDone({ success: true, message: t('update.alreadyLatest') });
|
||||
} else {
|
||||
setUpdateDone({ success: true, message: t('update.updateSuccess') });
|
||||
}
|
||||
} else {
|
||||
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + (data.error || '') });
|
||||
}
|
||||
setSourceProgress(null);
|
||||
} else {
|
||||
setSourceProgress(data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + (data.error || ''), logs: data.logs });
|
||||
}
|
||||
} catch (err) {
|
||||
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + err.message, logs: [] });
|
||||
setUpdateDone({ success: false, message: t('update.updateFailed') + ': ' + err.message });
|
||||
setSourceProgress(null);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
@@ -710,6 +736,30 @@ export default function HelpPanel({ open, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 源码更新进度条 */}
|
||||
{updating && sourceProgress && (
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, opacity: 0.8 }}>
|
||||
{sourceProgress.label}
|
||||
<span style={{ opacity: 0.5, marginLeft: 8 }}>
|
||||
({sourceProgress.step}/{sourceProgress.total})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%', height: 8, background: 'var(--bg-secondary)',
|
||||
borderRadius: 4, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${(sourceProgress.step / sourceProgress.total) * 100}%`,
|
||||
height: '100%',
|
||||
background: sourceProgress.status === 'error' ? '#ef4444' : 'var(--accent)',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 源码部署更新日志 */}
|
||||
{updateDone?.logs && updateDone.logs.length > 0 && (
|
||||
<div style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function UpdateBanner() {
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState(null); // { success, message, logs }
|
||||
const [downloadProgress, setDownloadProgress] = useState(null); // { progress, downloaded, total }
|
||||
const [sourceProgress, setSourceProgress] = useState(null); // { step, total, label, status }
|
||||
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectron;
|
||||
|
||||
@@ -70,25 +71,49 @@ export default function UpdateBanner() {
|
||||
}
|
||||
};
|
||||
|
||||
// 源码部署:git pull + build
|
||||
// 源码部署:SSE 流式更新
|
||||
const handleSourceUpdate = async () => {
|
||||
setUpdating(true);
|
||||
setUpdateResult(null);
|
||||
setSourceProgress(null);
|
||||
try {
|
||||
const res = await fetch('/api/update-source', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
const res = await fetch('/api/update-source-stream', { method: 'POST' });
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
if (data.success) {
|
||||
if (data.alreadyUpToDate) {
|
||||
setUpdateResult({ success: true, message: t('update.alreadyLatest'), logs: data.logs });
|
||||
} else {
|
||||
setUpdateResult({ success: true, message: t('update.updateSuccess'), logs: data.logs });
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const block of lines) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const data = JSON.parse(dataLine.slice(6));
|
||||
|
||||
if (data.done) {
|
||||
if (data.success) {
|
||||
if (data.alreadyUpToDate) {
|
||||
setUpdateResult({ success: true, message: t('update.alreadyLatest') });
|
||||
} else {
|
||||
setUpdateResult({ success: true, message: t('update.updateSuccess') });
|
||||
}
|
||||
} else {
|
||||
setUpdateResult({ success: false, message: t('update.updateFailed') + ': ' + (data.error || '') });
|
||||
}
|
||||
setSourceProgress(null);
|
||||
} else {
|
||||
setSourceProgress(data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUpdateResult({ success: false, message: t('update.updateFailed') + ': ' + (data.error || ''), logs: data.logs });
|
||||
}
|
||||
} catch (err) {
|
||||
setUpdateResult({ success: false, message: t('update.updateFailed') + ': ' + err.message, logs: [] });
|
||||
setUpdateResult({ success: false, message: t('update.updateFailed') + ': ' + err.message });
|
||||
setSourceProgress(null);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
@@ -128,7 +153,9 @@ export default function UpdateBanner() {
|
||||
{updating
|
||||
? (downloadProgress
|
||||
? `⬇️ ${downloadProgress.progress}%`
|
||||
: t('update.updating'))
|
||||
: sourceProgress
|
||||
? `${sourceProgress.label} (${sourceProgress.step}/${sourceProgress.total})`
|
||||
: t('update.updating'))
|
||||
: t('update.updateNow')
|
||||
}
|
||||
</button>
|
||||
@@ -148,6 +175,20 @@ export default function UpdateBanner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 源码更新进度条 */}
|
||||
{updating && sourceProgress && (
|
||||
<div style={{
|
||||
width: 120, height: 6, background: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 3, overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${(sourceProgress.step / sourceProgress.total) * 100}%`, height: '100%',
|
||||
background: sourceProgress.status === 'error' ? '#fca5a5' : '#a7f3d0',
|
||||
borderRadius: 3, transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新结果提示 */}
|
||||
{updateResult && (
|
||||
<span style={{
|
||||
|
||||
+2
-2
@@ -387,9 +387,9 @@ ipcMain.handle('download-and-install-update', async (event) => {
|
||||
|
||||
log(`Download complete: ${savePath}`);
|
||||
|
||||
// 4. 启动安装程序(静默模式)并退出当前应用
|
||||
// 4. 启动安装程序(显示进度条 + 完成对话框)并退出当前应用
|
||||
log('Launching installer...');
|
||||
const installer = spawn(savePath, ['/S'], {
|
||||
const installer = spawn(savePath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"runAfterFinish": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
|
||||
Reference in new issue
Block a user