feat: 更新安装显示进度条+完成对话框, 源码部署SSE流式进度

This commit is contained in:
YuanShiJiLoong committed 2026-03-02 07:02:34 +09:00
1 parent 82014ae64c
commit 49fadde800
5 files changed
+239 -26

No files matched your search

+122
View File
@@ -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
View File
@@ -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={{
+53 -12
View File
@@ -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
View File
@@ -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
View File
@@ -101,7 +101,7 @@
"oneClick": false,
"perMachine": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"allowToChangeInstallationDirectory": false,
"runAfterFinish": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true