Compare commits

...

2 Commits

3 changed files with 198 additions and 280 deletions

View File

@@ -1,211 +0,0 @@
<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"
: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">{{ displayPercent }}</span>
</div>
<div v-if="progress.message" class="progress-message">
<pre>{{ progress.message }}</pre>
</div>
<div v-if="!result" class="progress-actions">
<button class="btn btn-danger btn-sm" @click="emit('cancel')">
取消打包
</button>
</div>
<div v-if="result && result.error" class="progress-error">
<p>错误详情</p>
<pre>{{ result.error }}</pre>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
progress: {
type: Object,
default: () => ({ stage: '', message: '', percent: 0 })
},
result: {
type: Object,
default: null
}
})
const emit = defineEmits(['cancel'])
const stageLabel = computed(() => {
const labels = {
'starting': '准备开始',
'prepare': '准备中',
'copy-files': '复制文件',
'configure': '配置应用',
'install': '安装依赖',
'download': '下载中',
'build': '打包中',
'sign': '签名中',
'complete': '打包完成',
'error': '出错了'
}
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>
.pack-progress {
padding: 20px;
}
.progress-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-header h3 {
font-size: 15px;
font-weight: 600;
}
.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 {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
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 {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 13px;
}
.progress-label {
color: var(--text-secondary);
}
.progress-percent {
font-weight: 600;
color: var(--primary);
}
.progress-message {
margin-top: 12px;
font-size: 12px;
}
.progress-message pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: var(--radius);
max-height: 200px;
overflow-y: auto;
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.progress-actions {
margin-top: 16px;
}
.progress-error {
margin-top: 12px;
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--radius);
}
.progress-error p {
font-size: 13px;
color: var(--danger);
font-weight: 600;
margin-bottom: 8px;
}
.progress-error pre {
font-size: 12px;
color: #991b1b;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -1,46 +0,0 @@
import { defineStore } from 'pinia'
export const usePackagingStore = defineStore('packaging', {
state: () => ({
isPacking: false,
progress: {
stage: '',
message: '',
percent: 0
},
result: null
}),
actions: {
startPacking(projectPath, outputPath) {
this.isPacking = true
this.progress = { stage: 'starting', message: '准备开始...', percent: 0 }
this.result = null
const cleanup = window.api.pack.onProgress((progress) => {
this.progress = progress
})
window.api.pack.start(projectPath, outputPath).then((result) => {
this.isPacking = false
this.result = result
cleanup()
}).catch((err) => {
this.isPacking = false
this.result = { success: false, error: err.message }
cleanup()
})
},
cancelPacking() {
window.api.pack.cancel()
this.isPacking = false
},
reset() {
this.isPacking = false
this.progress = { stage: '', message: '', percent: 0 }
this.result = null
}
}
})

View File

@@ -38,41 +38,54 @@
</div> </div>
</div> </div>
<div v-if="!packStore.isPacking && !packStore.result" class="pack-actions"> <div v-if="!isPacking && !result" class="pack-actions">
<button class="btn btn-primary btn-start" @click="startPack" :disabled="!store.hasProject"> <button class="btn btn-primary btn-start" @click="startPack" :disabled="!store.hasProject">
开始打包 开始打包
</button> </button>
<p class="pack-hint">打包过程可能需要几分钟请耐心等待</p> <p class="pack-hint">打包过程可能需要几分钟请耐心等待</p>
</div> </div>
<PackProgress <div v-if="result && result.success" class="result-success card">
v-if="packStore.isPacking || packStore.result"
:progress="packStore.progress"
:result="packStore.result"
@cancel="packStore.cancelPacking()"
/>
<div v-if="packStore.result && packStore.result.success" class="result-success card">
<div class="result-icon">&#10003;</div> <div class="result-icon">&#10003;</div>
<h3>打包完成</h3> <h3>打包完成</h3>
<p>安装包已生成</p> <p>安装包已生成</p>
<p v-if="packStore.result.outputPath" class="output-path">{{ packStore.result.outputPath }}</p> <p v-if="result.outputPath" class="output-path">{{ result.outputPath }}</p>
<div class="result-actions"> <div class="result-actions">
<button class="btn btn-secondary" @click="goBack">返回编辑</button> <button class="btn btn-secondary" @click="goBack">返回编辑</button>
<button class="btn btn-primary" @click="startPack">重新打包</button> <button class="btn btn-primary" @click="startPack">重新打包</button>
</div> </div>
</div> </div>
<div v-if="packStore.result && !packStore.result.success" class="result-error card"> <div v-if="result && !result.success" class="result-error card">
<div class="result-icon error">&#10007;</div> <div class="result-icon error">&#10007;</div>
<h3>打包失败</h3> <h3>打包失败</h3>
<p>{{ packStore.result.error }}</p> <p>{{ result.error }}</p>
<div class="result-actions"> <div class="result-actions">
<button class="btn btn-secondary" @click="goBack">返回编辑</button> <button class="btn btn-secondary" @click="goBack">返回编辑</button>
<button class="btn btn-primary" @click="startPack">重试</button> <button class="btn btn-primary" @click="startPack">重试</button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="showOverlay" class="pack-overlay">
<div class="overlay-content">
<div class="overlay-spinner">
<div class="spinner-ring"></div>
</div>
<div class="overlay-stage">{{ stageLabel }}</div>
<div class="overlay-bar">
<div
class="overlay-bar-fill"
:class="{ pulse: isIndeterminate }"
:style="barStyle"
></div>
</div>
<div v-if="progressMsg" class="overlay-msg">{{ progressMsg }}</div>
<button v-if="isPacking" class="btn btn-danger btn-sm overlay-cancel" @click="cancelPack">
取消打包
</button>
</div>
</div>
</div> </div>
</template> </template>
@@ -80,18 +93,22 @@
import { ref, computed, onBeforeUnmount } from 'vue' import { ref, computed, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import { usePackagingStore } from '../stores/packaging'
import PackProgress from '../components/PackProgress.vue'
const router = useRouter() const router = useRouter()
const store = useProjectStore() const store = useProjectStore()
const packStore = usePackagingStore()
const outputPath = ref(null)
if (!store.hasProject) { if (!store.hasProject) {
router.replace('/') router.replace('/')
} }
const outputPath = ref(null)
const isPacking = ref(false)
const result = ref(null)
const progressStage = ref('')
const progressMsg = ref('')
const progressPct = ref(0)
const showOverlay = ref(false)
const fileCount = computed(() => countFiles(store.files)) const fileCount = computed(() => countFiles(store.files))
const platformLabel = computed(() => { const platformLabel = computed(() => {
@@ -102,6 +119,74 @@ const platformLabel = computed(() => {
return p return p
}) })
const stageLabel = computed(() => {
const labels = {
'starting': '准备开始…',
'prepare': '准备中…',
'copy-files': '复制文件…',
'configure': '配置应用…',
'install': '安装依赖…',
'download': '下载中…',
'build': '打包中…',
'sign': '签名中…',
'complete': '打包完成',
'error': '出错了'
}
return labels[progressStage.value] || progressStage.value || '准备开始…'
})
const isIndeterminate = computed(() => {
return ['starting', 'install', 'download'].includes(progressStage.value)
})
const barStyle = computed(() => {
if (isIndeterminate.value) return {}
return { width: Math.round(progressPct.value) + '%' }
})
async function startPack() {
isPacking.value = true
result.value = null
progressStage.value = 'starting'
progressMsg.value = '正在准备打包环境…'
progressPct.value = 0
showOverlay.value = true
const cleanup = window.api.pack.onProgress((p) => {
progressStage.value = p.stage
progressMsg.value = p.message
if (p.percent != null) progressPct.value = p.percent
})
try {
const res = await window.api.pack.start(store.projectPath, outputPath.value)
isPacking.value = false
result.value = res
if (res.error) {
progressStage.value = 'error'
progressMsg.value = res.error
} else {
progressStage.value = 'complete'
progressMsg.value = '打包完成!'
progressPct.value = 100
}
} catch (err) {
isPacking.value = false
result.value = { success: false, error: err.message }
progressStage.value = 'error'
progressMsg.value = err.message
}
cleanup?.()
}
function cancelPack() {
window.api.pack.cancel()
isPacking.value = false
showOverlay.value = false
progressStage.value = ''
progressMsg.value = ''
}
async function selectOutputPath() { async function selectOutputPath() {
const ext = process.platform === 'win32' ? 'exe' : process.platform === 'darwin' ? 'dmg' : 'AppImage' const ext = process.platform === 'win32' ? 'exe' : process.platform === 'darwin' ? 'dmg' : 'AppImage'
const path = await window.api.dialog.saveFile({ const path = await window.api.dialog.saveFile({
@@ -111,12 +196,10 @@ async function selectOutputPath() {
if (path) outputPath.value = path if (path) outputPath.value = path
} }
function startPack() {
packStore.startPacking(store.projectPath, outputPath.value)
}
function goBack() { function goBack() {
packStore.reset() showOverlay.value = false
isPacking.value = false
result.value = null
router.push('/editor') router.push('/editor')
} }
@@ -130,8 +213,8 @@ function countFiles(files) {
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (!packStore.result) { if (isPacking.value) {
packStore.reset() window.api.pack.cancel()
} }
}) })
</script> </script>
@@ -252,4 +335,96 @@ onBeforeUnmount(() => {
word-break: break-all; word-break: break-all;
max-width: 100%; max-width: 100%;
} }
.pack-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.overlay-content {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 32px 40px;
min-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.overlay-spinner {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.spinner-ring {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.overlay-stage {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.overlay-bar {
width: 100%;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.overlay-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #818cf8);
border-radius: 3px;
transition: width 0.25s ease;
}
.overlay-bar-fill.pulse {
width: 28%;
animation: slide 1.2s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%); }
50% { transform: translateX(200%); }
100% { transform: translateX(400%); }
}
.overlay-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
word-break: break-all;
max-width: 360px;
}
.overlay-cancel {
margin-top: 4px;
}
</style> </style>