From 49fadde8008b79cf059747727ddce20ea4714082 Mon Sep 17 00:00:00 2001 From: YuanShiJiLoong <2450829984@qq.com> Date: Mon, 2 Mar 2026 07:02:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=BA=A6=E6=9D=A1+=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=AF=B9=E8=AF=9D=E6=A1=86,=20=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E9=83=A8=E7=BD=B2SSE=E6=B5=81=E5=BC=8F=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/update-source-stream/route.js | 122 ++++++++++++++++++++++++++ app/components/HelpPanel.js | 72 ++++++++++++--- app/components/UpdateBanner.js | 65 +++++++++++--- electron/main.js | 4 +- package.json | 2 +- 5 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 app/api/update-source-stream/route.js diff --git a/app/api/update-source-stream/route.js b/app/api/update-source-stream/route.js new file mode 100644 index 0000000..c3575c1 --- /dev/null +++ b/app/api/update-source-stream/route.js @@ -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); + }); +} diff --git a/app/components/HelpPanel.js b/app/components/HelpPanel.js index 1f00827..5e394db 100644 --- a/app/components/HelpPanel.js +++ b/app/components/HelpPanel.js @@ -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 }) { )} + {/* 源码更新进度条 */} + {updating && sourceProgress && ( +