fix: 重写打包逻辑为全异步,新增等待动画和实时进度流
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs')
|
||||
const fsp = fs.promises
|
||||
const path = require('path')
|
||||
const { exec, spawn } = require('child_process')
|
||||
const { spawn } = require('child_process')
|
||||
const { getConfigPath, getSrcDir } = require('./project')
|
||||
|
||||
let isCancelled = false
|
||||
@@ -14,13 +15,60 @@ function cancelPack() {
|
||||
}
|
||||
}
|
||||
|
||||
function runAsync(cmd, args, options) {
|
||||
function yieldToEventLoop() {
|
||||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function copyTree(src, dest) {
|
||||
await fsp.mkdir(dest, { recursive: true })
|
||||
const entries = await fsp.readdir(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const s = path.join(src, entry.name)
|
||||
const d = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await copyTree(s, d)
|
||||
} else {
|
||||
await fsp.copyFile(s, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyContents(src, dest) {
|
||||
if (!fs.existsSync(src)) return
|
||||
await fsp.mkdir(dest, { recursive: true })
|
||||
const entries = await fsp.readdir(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const s = path.join(src, entry.name)
|
||||
const d = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await copyTree(s, d)
|
||||
} else {
|
||||
await fsp.copyFile(s, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runWithProgress(cmd, args, options, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { ...options, shell: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (d) => { stdout += d.toString() })
|
||||
child.stderr.on('data', (d) => { stderr += d.toString() })
|
||||
|
||||
child.stdout.on('data', (d) => {
|
||||
const text = d.toString()
|
||||
stdout += text
|
||||
const line = text.trim()
|
||||
if (line) {
|
||||
onProgress({ stage: 'install', message: line, percent: 50 })
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr.on('data', (d) => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve(stdout)
|
||||
@@ -32,33 +80,40 @@ function runAsync(cmd, args, options) {
|
||||
async function startPack(projectPath, outputPath, onProgress) {
|
||||
isCancelled = false
|
||||
buildingProcess = null
|
||||
const config = JSON.parse(fs.readFileSync(getConfigPath(projectPath), 'utf-8'))
|
||||
const config = JSON.parse(await fsp.readFile(getConfigPath(projectPath), 'utf-8'))
|
||||
const srcDir = getSrcDir(projectPath)
|
||||
const tempDir = path.join(projectPath, '.web2app', 'temp-build')
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
|
||||
onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
await fsp.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
onProgress({ stage: 'prepare', message: '复制模板文件...', percent: 10 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
copyTemplateSync(tempDir)
|
||||
const templateDir = path.join(__dirname, '..', 'template')
|
||||
await copyTree(templateDir, tempDir)
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
const frontendDest = path.join(tempDir, 'frontend')
|
||||
fs.mkdirSync(frontendDest, { recursive: true })
|
||||
copyDirContentsSync(srcDir, frontendDest)
|
||||
await fsp.mkdir(frontendDest, { recursive: true })
|
||||
await copyContents(srcDir, frontendDest)
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
const templatePackagePath = path.join(tempDir, 'package.json')
|
||||
const templatePackage = JSON.parse(fs.readFileSync(templatePackagePath, 'utf-8'))
|
||||
const templatePackage = JSON.parse(await fsp.readFile(templatePackagePath, 'utf-8'))
|
||||
templatePackage.name = config.name.toLowerCase().replace(/\s+/g, '-')
|
||||
templatePackage.productName = config.name
|
||||
templatePackage.version = config.version
|
||||
@@ -109,15 +164,20 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
icon: config.icon ? path.join(frontendDest, config.icon) : undefined
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(templatePackagePath, JSON.stringify(templatePackage, null, 2))
|
||||
await fsp.writeFile(templatePackagePath, JSON.stringify(templatePackage, null, 2))
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
onProgress({ stage: 'install', message: '正在安装依赖...', percent: 45 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
try {
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
onProgress({ stage: 'install', message: '正在安装依赖 (首次可能需要几分钟)...', percent: 50 })
|
||||
await runAsync(npmCmd, ['install', '--production'], { cwd: tempDir, timeout: 300000 })
|
||||
await yieldToEventLoop()
|
||||
await runWithProgress(npmCmd, ['install', '--production'], {
|
||||
cwd: tempDir,
|
||||
timeout: 300000
|
||||
}, onProgress)
|
||||
} catch (err) {
|
||||
onProgress({ stage: 'error', message: `依赖安装失败: ${err.message}`, percent: 0 })
|
||||
return { success: false, error: err.message }
|
||||
@@ -125,6 +185,7 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
|
||||
if (isCancelled) return { success: false, cancelled: true }
|
||||
onProgress({ stage: 'build', message: '正在打包生成安装包...', percent: 65 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const builderPath = path.join(tempDir, 'node_modules', '.bin', 'electron-builder')
|
||||
@@ -140,20 +201,18 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
|
||||
buildingProcess.stdout.on('data', (data) => {
|
||||
buildOutput += data.toString()
|
||||
const msg = data.toString()
|
||||
|
||||
const msg = data.toString().trim()
|
||||
if (msg) {
|
||||
if (msg.includes('packaging')) {
|
||||
onProgress({ stage: 'build', message: '正在打包...', percent: 75 })
|
||||
} else if (msg.includes('signing')) {
|
||||
onProgress({ stage: 'sign', message: '正在签名...', percent: 85 })
|
||||
} else if (msg.includes('downloading')) {
|
||||
onProgress({ stage: 'download', message: msg.trim(), percent: 55 })
|
||||
onProgress({ stage: 'download', message: msg, percent: 55 })
|
||||
} else if (msg.includes('building')) {
|
||||
onProgress({ stage: 'build', message: msg.trim(), percent: 70 })
|
||||
onProgress({ stage: 'build', message: msg, percent: 70 })
|
||||
} else {
|
||||
const line = msg.trim()
|
||||
if (line) {
|
||||
onProgress({ stage: 'build', message: line, percent: 65 })
|
||||
onProgress({ stage: 'build', message: msg, percent: 65 })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -162,7 +221,7 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
buildOutput += data.toString()
|
||||
})
|
||||
|
||||
buildingProcess.on('close', (code) => {
|
||||
buildingProcess.on('close', async (code) => {
|
||||
buildingProcess = null
|
||||
if (isCancelled) {
|
||||
resolve({ success: false, cancelled: true })
|
||||
@@ -177,21 +236,23 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
}
|
||||
|
||||
onProgress({ stage: 'complete', message: '打包完成!', percent: 100 })
|
||||
await yieldToEventLoop()
|
||||
|
||||
const distDir = path.join(tempDir, 'dist')
|
||||
let installerPath = null
|
||||
if (fs.existsSync(distDir)) {
|
||||
const files = fs.readdirSync(distDir)
|
||||
const files = (await fsp.readdir(distDir))
|
||||
.filter(f => f.endsWith('.exe') || f.endsWith('.dmg') || f.endsWith('.AppImage') || f.endsWith('.deb'))
|
||||
.sort((a, b) => fs.statSync(path.join(distDir, b)).mtimeMs - fs.statSync(path.join(distDir, a)).mtimeMs)
|
||||
.map(f => ({ name: f, mtime: fs.statSync(path.join(distDir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.map(f => f.name)
|
||||
if (files.length > 0) {
|
||||
installerPath = path.join(distDir, files[0])
|
||||
if (outputPath) {
|
||||
try {
|
||||
fs.copyFileSync(installerPath, outputPath)
|
||||
await fsp.copyFile(installerPath, outputPath)
|
||||
installerPath = outputPath
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,54 +267,4 @@ async function startPack(projectPath, outputPath, onProgress) {
|
||||
})
|
||||
}
|
||||
|
||||
function copyTemplateSync(destDir) {
|
||||
const templateDir = path.join(__dirname, '..', 'template')
|
||||
const entries = fs.readdirSync(templateDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const srcPath = path.join(templateDir, entry.name)
|
||||
const destPath = path.join(destDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath)
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(destDir, 'frontend'), { recursive: true })
|
||||
fs.mkdirSync(path.join(destDir, 'backend'), { recursive: true })
|
||||
}
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const srcPath = path.join(src, entry.name)
|
||||
const destPath = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath)
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirContentsSync(src, dest) {
|
||||
if (!fs.existsSync(src)) return
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const srcPath = path.join(src, entry.name)
|
||||
const destPath = path.join(dest, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath)
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startPack, cancelPack }
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
<template>
|
||||
<div class="pack-progress card">
|
||||
<div class="progress-header">
|
||||
<div class="spinner" :class="{ active: !result }">
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
<h3>打包进度</h3>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: progress.percent + '%' }"></div>
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="{ indeterminate: isIndeterminate }"
|
||||
:style="barStyle"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-if="progress.stage" class="progress-info">
|
||||
<span class="progress-label">{{ stageLabel }}</span>
|
||||
<span class="progress-percent">{{ Math.round(progress.percent) }}%</span>
|
||||
<span class="progress-percent">{{ displayPercent }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="progress.message" class="progress-message">
|
||||
@@ -46,10 +55,12 @@ const emit = defineEmits(['cancel'])
|
||||
|
||||
const stageLabel = computed(() => {
|
||||
const labels = {
|
||||
'starting': '准备开始',
|
||||
'prepare': '准备中',
|
||||
'copy-files': '复制文件',
|
||||
'configure': '配置应用',
|
||||
'install': '安装依赖',
|
||||
'download': '下载中',
|
||||
'build': '打包中',
|
||||
'sign': '签名中',
|
||||
'complete': '打包完成',
|
||||
@@ -57,6 +68,20 @@ const stageLabel = computed(() => {
|
||||
}
|
||||
return labels[props.progress.stage] || props.progress.stage
|
||||
})
|
||||
|
||||
const isIndeterminate = computed(() => {
|
||||
return ['install', 'download'].includes(props.progress.stage)
|
||||
})
|
||||
|
||||
const barStyle = computed(() => {
|
||||
if (isIndeterminate.value) return {}
|
||||
return { width: Math.round(props.progress.percent) + '%' }
|
||||
})
|
||||
|
||||
const displayPercent = computed(() => {
|
||||
if (isIndeterminate.value) return '进行中...'
|
||||
return Math.round(props.progress.percent) + '%'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -64,10 +89,37 @@ const stageLabel = computed(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pack-progress h3 {
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner.active .spinner-ring {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2.5px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
@@ -83,6 +135,18 @@ const stageLabel = computed(() => {
|
||||
background: var(--primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-bar.indeterminate {
|
||||
width: 30%;
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(200%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
@@ -115,6 +179,8 @@ const stageLabel = computed(() => {
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.progress-actions {
|
||||
|
||||
Reference in New Issue
Block a user