Compare commits

..

7 Commits

Author SHA1 Message Date
gmh01
e143eebee7 refactor: 重写打包流程,移除 Pinia 改用本地状态 + 全屏叠加层动画 2026-07-23 21:26:55 +08:00
gmh01
5e890108a1 fix: 修复进度监听器泄露问题,添加 nextTick 确保动画先于 IPC 渲染 2026-07-23 21:12:45 +08:00
gmh01
afdfce6502 fix: 重写打包逻辑为全异步,新增等待动画和实时进度流 2026-07-23 21:04:25 +08:00
gmh01
482a79f1c7 fix: 替换 execSync 为异步 runAsync 避免打包时主进程阻塞白屏 2026-07-23 20:53:11 +08:00
gmh01
90d3404cd8 docs: 更新需求文档以匹配实际实现
- 更新新建项目流程(选定文件夹即项目)
- 更新资源导入方式(目录导入 + 文件选择)
- 更新技术方案(Vue 3 + Pinia + Vite 5)
- 更新界面布局描述
2026-07-23 20:42:38 +08:00
gmh01
8dc102a7cb fix: 重构新建项目逻辑,选定文件夹即项目
- 移除 prompt() 及 InputDialog 组件依赖
- 选定文件夹直接创建 .web2app 项目结构
- 默认以文件夹名作为项目名称
- 在编辑器中可自由修改名称、版本、图标、入口等配置
- 增强导入资源入口的文案提示
2026-07-23 20:40:45 +08:00
gmh01
686d94e178 fix: 修复新建项目无响应问题
- 替换 prompt() 为 InputDialog Vue 模态组件
- 替换 alert()/confirm() 为 Electron showMessageBox IPC
- 添加 dialog:showMessage / dialog:showConfirm IPC 通道
2026-07-23 19:44:04 +08:00
15 changed files with 530 additions and 349 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules/
dist/ dist/
.web2app/ .web2app/
release/ release/
installer/
__appImage-x64/
# Binaries & executables # Binaries & executables
*.exe *.exe

View File

@@ -1,43 +1,119 @@
const fs = require('fs') const fs = require('fs')
const fsp = fs.promises
const path = require('path') const path = require('path')
const { execSync, spawn } = require('child_process') const { spawn } = require('child_process')
const { getConfigPath, getSrcDir } = require('./project') const { getConfigPath, getSrcDir } = require('./project')
let isCancelled = false let isCancelled = false
let buildingProcess = null
function cancelPack() { function cancelPack() {
isCancelled = true isCancelled = true
if (buildingProcess) {
try { buildingProcess.kill() } catch (e) {}
buildingProcess = null
}
}
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) => {
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)
else reject(new Error(stderr || stdout || `进程退出码 ${code}`))
})
})
} }
async function startPack(projectPath, outputPath, onProgress) { async function startPack(projectPath, outputPath, onProgress) {
isCancelled = false isCancelled = false
const config = JSON.parse(fs.readFileSync(getConfigPath(projectPath), 'utf-8')) buildingProcess = null
const config = JSON.parse(await fsp.readFile(getConfigPath(projectPath), 'utf-8'))
const srcDir = getSrcDir(projectPath) const srcDir = getSrcDir(projectPath)
const tempDir = path.join(projectPath, '.web2app', 'temp-build') const tempDir = path.join(projectPath, '.web2app', 'temp-build')
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 }) onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 })
await yieldToEventLoop()
if (fs.existsSync(tempDir)) { 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 } if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'prepare', message: '复制模板文件...', percent: 10 }) 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 } if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 }) onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 })
await yieldToEventLoop()
const frontendDest = path.join(tempDir, 'frontend') const frontendDest = path.join(tempDir, 'frontend')
fs.mkdirSync(frontendDest, { recursive: true }) await fsp.mkdir(frontendDest, { recursive: true })
copyDirContentsSync(srcDir, frontendDest) await copyContents(srcDir, frontendDest)
if (isCancelled) return { success: false, cancelled: true } if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 }) onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 })
await yieldToEventLoop()
const templatePackagePath = path.join(tempDir, 'package.json') 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.name = config.name.toLowerCase().replace(/\s+/g, '-')
templatePackage.productName = config.name templatePackage.productName = config.name
templatePackage.version = config.version templatePackage.version = config.version
@@ -88,31 +164,34 @@ async function startPack(projectPath, outputPath, onProgress) {
icon: config.icon ? path.join(frontendDest, config.icon) : undefined 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 } if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'install', message: '安装依赖 (首次较慢)...', percent: 50 }) onProgress({ stage: 'install', message: '正在安装依赖...', percent: 45 })
await yieldToEventLoop()
try { try {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execSync(`${npmCmd} install --production`, { onProgress({ stage: 'install', message: '正在安装依赖 (首次可能需要几分钟)...', percent: 50 })
await yieldToEventLoop()
await runWithProgress(npmCmd, ['install', '--production'], {
cwd: tempDir, cwd: tempDir,
stdio: 'pipe',
timeout: 300000 timeout: 300000
}) }, onProgress)
} catch (err) { } catch (err) {
onProgress({ stage: 'install', message: `依赖安装失败: ${err.message}`, percent: 50 }) onProgress({ stage: 'error', message: `依赖安装失败: ${err.message}`, percent: 0 })
return { success: false, error: err.message } return { success: false, error: err.message }
} }
if (isCancelled) return { success: false, cancelled: true } if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'build', message: '正在打包生成安装包...', percent: 65 }) onProgress({ stage: 'build', message: '正在打包生成安装包...', percent: 65 })
await yieldToEventLoop()
return new Promise((resolve) => { return new Promise((resolve) => {
const builderPath = path.join(tempDir, 'node_modules', '.bin', 'electron-builder') const builderPath = path.join(tempDir, 'node_modules', '.bin', 'electron-builder')
const builderCmd = process.platform === 'win32' ? `${builderPath}.cmd` : builderPath const builderCmd = process.platform === 'win32' ? `${builderPath}.cmd` : builderPath
const builderProcess = spawn(builderCmd, ['--config', templatePackagePath], { buildingProcess = spawn(builderCmd, ['--config', templatePackagePath], {
cwd: tempDir, cwd: tempDir,
shell: true, shell: true,
stdio: ['pipe', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe']
@@ -120,24 +199,30 @@ async function startPack(projectPath, outputPath, onProgress) {
let buildOutput = '' let buildOutput = ''
builderProcess.stdout.on('data', (data) => { buildingProcess.stdout.on('data', (data) => {
buildOutput += data.toString() buildOutput += data.toString()
const msg = data.toString() const msg = data.toString().trim()
if (msg) {
if (msg.includes('packaging')) { if (msg.includes('packaging')) {
onProgress({ stage: 'build', message: '正在打包...', percent: 75 }) onProgress({ stage: 'build', message: '正在打包...', percent: 75 })
} else if (msg.includes('signing')) { } else if (msg.includes('signing')) {
onProgress({ stage: 'sign', message: '正在签名...', percent: 85 }) onProgress({ stage: 'sign', message: '正在签名...', percent: 85 })
} else { } else if (msg.includes('downloading')) {
onProgress({ stage: 'build', message: msg.trim(), percent: 75 }) onProgress({ stage: 'download', message: msg, percent: 55 })
} else if (msg.includes('building')) {
onProgress({ stage: 'build', message: msg, percent: 70 })
} else {
onProgress({ stage: 'build', message: msg, percent: 65 })
}
} }
}) })
builderProcess.stderr.on('data', (data) => { buildingProcess.stderr.on('data', (data) => {
buildOutput += data.toString() buildOutput += data.toString()
}) })
builderProcess.on('close', (code) => { buildingProcess.on('close', async (code) => {
buildingProcess = null
if (isCancelled) { if (isCancelled) {
resolve({ success: false, cancelled: true }) resolve({ success: false, cancelled: true })
return return
@@ -151,21 +236,23 @@ async function startPack(projectPath, outputPath, onProgress) {
} }
onProgress({ stage: 'complete', message: '打包完成!', percent: 100 }) onProgress({ stage: 'complete', message: '打包完成!', percent: 100 })
await yieldToEventLoop()
const distDir = path.join(tempDir, 'dist') const distDir = path.join(tempDir, 'dist')
let installerPath = null let installerPath = null
if (fs.existsSync(distDir)) { 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')) .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) { if (files.length > 0) {
installerPath = path.join(distDir, files[0]) installerPath = path.join(distDir, files[0])
if (outputPath) { if (outputPath) {
try { try {
fs.copyFileSync(installerPath, outputPath) await fsp.copyFile(installerPath, outputPath)
installerPath = outputPath installerPath = outputPath
} catch (e) { } catch (e) {}
}
} }
} }
} }
@@ -173,60 +260,11 @@ async function startPack(projectPath, outputPath, onProgress) {
resolve({ success: true, outputPath: installerPath }) resolve({ success: true, outputPath: installerPath })
}) })
builderProcess.on('error', (err) => { buildingProcess.on('error', (err) => {
buildingProcess = null
resolve({ success: false, error: err.message }) resolve({ success: false, error: err.message })
}) })
}) })
} }
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 } module.exports = { startPack, cancelPack }

View File

@@ -85,12 +85,34 @@ ipcMain.handle('dialog:saveFile', async (_event, options) => {
return null return null
}) })
ipcMain.handle('dialog:showMessage', async (_event, options) => {
const result = await dialog.showMessageBox(mainWindow, {
type: options?.type || 'info',
buttons: options?.buttons || ['确定'],
title: options?.title || 'Web2App',
message: options?.message || ''
})
return result.response
})
ipcMain.handle('dialog:showConfirm', async (_event, options) => {
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: ['取消', '确定'],
defaultId: 1,
cancelId: 0,
title: options?.title || 'Web2App',
message: options?.message || '确认?'
})
return result.response === 1
})
ipcMain.handle('dir:readTree', async (_event, dirPath) => { ipcMain.handle('dir:readTree', async (_event, dirPath) => {
return readDirectoryTree(dirPath) return readDirectoryTree(dirPath)
}) })
ipcMain.handle('project:create', async (_event, name, dir) => { ipcMain.handle('project:create', async (_event, projectPath) => {
return createProject(name, dir) return createProject(projectPath)
}) })
ipcMain.handle('project:load', async (_event, projectPath) => { ipcMain.handle('project:load', async (_event, projectPath) => {

View File

@@ -5,13 +5,15 @@ contextBridge.exposeInMainWorld('api', {
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'), selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
selectFile: (options) => ipcRenderer.invoke('dialog:selectFile', options), selectFile: (options) => ipcRenderer.invoke('dialog:selectFile', options),
selectIcon: () => ipcRenderer.invoke('dialog:selectIcon'), selectIcon: () => ipcRenderer.invoke('dialog:selectIcon'),
saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options) saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options),
showMessage: (options) => ipcRenderer.invoke('dialog:showMessage', options),
showConfirm: (options) => ipcRenderer.invoke('dialog:showConfirm', options)
}, },
dir: { dir: {
readTree: (dirPath) => ipcRenderer.invoke('dir:readTree', dirPath) readTree: (dirPath) => ipcRenderer.invoke('dir:readTree', dirPath)
}, },
project: { project: {
create: (name, dir) => ipcRenderer.invoke('project:create', name, dir), create: (projectPath) => ipcRenderer.invoke('project:create', projectPath),
load: (projectPath) => ipcRenderer.invoke('project:load', projectPath), load: (projectPath) => ipcRenderer.invoke('project:load', projectPath),
save: (projectPath, data) => ipcRenderer.invoke('project:save', projectPath, data), save: (projectPath, data) => ipcRenderer.invoke('project:save', projectPath, data),
importFiles: (projectPath, files) => ipcRenderer.invoke('project:importFiles', projectPath, files), importFiles: (projectPath, files) => ipcRenderer.invoke('project:importFiles', projectPath, files),

View File

@@ -21,16 +21,21 @@ function getDistDir(projectPath) {
return dir return dir
} }
function createProject(name, dir) { function createProject(projectPath) {
const projectPath = path.join(dir, name) if (!fs.existsSync(projectPath)) {
if (fs.existsSync(projectPath)) { fs.mkdirSync(projectPath, { recursive: true })
throw new Error(`目录 ${projectPath} 已存在`)
} }
const configPath = getConfigPath(projectPath)
if (fs.existsSync(configPath)) {
throw new Error(`该目录已是 Web2App 项目`)
}
fs.mkdirSync(path.join(projectPath, '.web2app', 'src'), { recursive: true }) fs.mkdirSync(path.join(projectPath, '.web2app', 'src'), { recursive: true })
fs.mkdirSync(path.join(projectPath, '.web2app', 'dist'), { recursive: true }) fs.mkdirSync(path.join(projectPath, '.web2app', 'dist'), { recursive: true })
const config = { const config = {
name, name: path.basename(projectPath),
version: '1.0.0', version: '1.0.0',
description: '', description: '',
author: '', author: '',
@@ -47,7 +52,7 @@ function createProject(name, dir) {
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
} }
fs.writeFileSync(getConfigPath(projectPath), JSON.stringify(config, null, 2)) fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
return { projectPath, config } return { projectPath, config }
} }

View File

@@ -2,19 +2,26 @@
"name": "web2app", "name": "web2app",
"version": "1.0.0", "version": "1.0.0",
"description": "将网站转化为独立运行的桌面应用", "description": "将网站转化为独立运行的桌面应用",
"homepage": "https://github.com/web2app/web2app",
"author": {
"name": "Web2App Team",
"email": "team@web2app.dev"
},
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"", "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "vite build && electron-builder --config", "electron:build": "vite build && electron-builder --config",
"electron:build:linux": "vite build && electron-builder --config --linux",
"electron:build:deb": "vite build && electron-builder --config --linux deb",
"preview": "vite preview" "preview": "vite preview"
}, },
"build": { "build": {
"appId": "com.web2app.app", "appId": "com.web2app.app",
"productName": "Web2App", "productName": "Web2App",
"directories": { "directories": {
"output": "release" "output": "installer"
}, },
"files": [ "files": [
"electron/**/*", "electron/**/*",
@@ -55,7 +62,8 @@
} }
], ],
"icon": null, "icon": null,
"category": "Utility" "category": "Utility",
"maintainer": "Web2App Team <team@web2app.dev>"
} }
}, },
"dependencies": { "dependencies": {

View File

@@ -344,23 +344,25 @@
<div class="platform-icon">🪟</div> <div class="platform-icon">🪟</div>
<h4>Windows</h4> <h4>Windows</h4>
<p class="version">v1.0.0 · x64</p> <p class="version">v1.0.0 · x64</p>
<a href="../release/win-unpacked/Web2App.exe" class="btn btn-primary" download> <a href="https://web2app.qmiweb.com/resource/Web2App%20Setup%201.0.0.exe" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
下载便携版 下载安装包
</a> </a>
<div style="margin-top:10px;font-size:13px;color:var(--text-muted);">可执行安装包请通过 <code>npm run electron:build</code> 构建</div> <div style="margin-top:10px;font-size:13px;color:var(--text-muted);">Windows 7+ / x64 · 含安装向导</div>
</div> </div>
<div class="platform-card"> <div class="platform-card">
<div class="platform-icon">🍎</div> <div class="platform-icon">🍎</div>
<h4>macOS</h4> <h4>macOS</h4>
<p class="version">v1.0.0 · Intel / Apple Silicon</p> <p class="version">v1.0.0 · Intel / Apple Silicon</p>
<span class="coming-soon">即将发布</span> <span class="coming-soon">即将发布</span>
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">可通过源码自行构建</div>
</div> </div>
<div class="platform-card"> <div class="platform-card">
<div class="platform-icon">🐧</div> <div class="platform-icon">🐧</div>
<h4>Linux</h4> <h4>Linux</h4>
<p class="version">v1.0.0 · .AppImage</p> <p class="version">v1.0.0 · .AppImage / .deb</p>
<span class="coming-soon">即将发布</span> <span class="coming-soon">即将发布</span>
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">可通过源码自行构建</div>
</div> </div>
</div> </div>
</div> </div>
@@ -409,7 +411,7 @@
<footer> <footer>
<div class="container"> <div class="container">
<span>&copy; 2026 Web2App. MIT License.</span> <span>&copy; 2026 Web2App. MIT License.</span>
<a href="https://github.com/anomalyco/WebpageExer" target="_blank">GitHub 仓库</a> <a href="https://git.qmikeji.cn/gmh/Web2App.git" target="_blank">Git 仓库</a>
</div> </div>
</footer> </footer>

View File

@@ -0,0 +1,100 @@
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-overlay" @click.self="cancel">
<div class="dialog-box card">
<h3 class="dialog-title">{{ title }}</h3>
<p v-if="message" class="dialog-message">{{ message }}</p>
<input
ref="inputRef"
class="dialog-input"
v-model="inputValue"
:placeholder="placeholder"
@keydown.enter="confirm"
@keydown.esc="cancel"
/>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="cancel">{{ cancelText }}</button>
<button class="btn btn-primary" @click="confirm" :disabled="!inputValue.trim()">{{ okText }}</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
visible: Boolean,
title: { type: String, default: '输入' },
message: { type: String, default: '' },
placeholder: { type: String, default: '' },
defaultValue: { type: String, default: '' },
cancelText: { type: String, default: '取消' },
okText: { type: String, default: '确定' }
})
const emit = defineEmits(['confirm', 'cancel'])
const inputValue = ref('')
const inputRef = ref(null)
watch(() => props.visible, (val) => {
if (val) {
inputValue.value = props.defaultValue
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
})
function confirm() {
if (inputValue.value.trim()) {
emit('confirm', inputValue.value.trim())
}
}
function cancel() {
emit('cancel')
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-box {
width: 400px;
padding: 24px;
}
.dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.dialog-message {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 12px;
}
.dialog-input {
width: 100%;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -1,145 +0,0 @@
<template>
<div class="pack-progress card">
<h3>打包进度</h3>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: progress.percent + '%' }"></div>
</div>
<div v-if="progress.percent > 0" class="progress-info">
<span class="progress-label">{{ stageLabel }}</span>
<span class="progress-percent">{{ Math.round(progress.percent) }}%</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 = {
'prepare': '准备中',
'copy-files': '复制文件',
'configure': '配置应用',
'install': '安装依赖',
'build': '打包中',
'sign': '签名中',
'complete': '打包完成',
'error': '出错了'
}
return labels[props.progress.stage] || props.progress.stage
})
</script>
<style scoped>
.pack-progress {
padding: 20px;
}
.pack-progress h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
}
.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;
}
.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;
}
.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

@@ -28,8 +28,8 @@ export const useProjectStore = defineStore('project', {
}, },
actions: { actions: {
async createProject(name, dir) { async createProject(projectPath) {
const result = await window.api.project.create(name, dir) const result = await window.api.project.create(projectPath)
this.projectPath = result.projectPath this.projectPath = result.projectPath
this.config = result.config this.config = result.config
this.files = [] this.files = []

View File

@@ -12,13 +12,13 @@
<div class="action-card" @click="createNewProject"> <div class="action-card" @click="createNewProject">
<span class="action-icon">+</span> <span class="action-icon">+</span>
<span class="action-title">新建项目</span> <span class="action-title">新建项目</span>
<span class="action-desc">创建新的 Web2App 项目</span> <span class="action-desc">选择文件夹快速创建项目</span>
</div> </div>
<div class="action-card" @click="openExistingProject"> <div class="action-card" @click="openExistingProject">
<span class="action-icon">&#9776;</span> <span class="action-icon">&#9776;</span>
<span class="action-title">打开项目</span> <span class="action-title">打开项目</span>
<span class="action-desc">打开已有的 .web2app 项目</span> <span class="action-desc">打开已有的 Web2App 项目</span>
</div> </div>
<div class="action-card" @click="goHelp"> <div class="action-card" @click="goHelp">
@@ -57,14 +57,16 @@ const recentProjects = ref(loadRecentProjects())
async function createNewProject() { async function createNewProject() {
const dir = await window.api.dialog.selectDirectory() const dir = await window.api.dialog.selectDirectory()
if (!dir) return if (!dir) return
const name = prompt('请输入项目名称:')
if (!name) return
try { try {
await store.createProject(name, dir) await store.createProject(dir)
addRecentProject(dir, name) addRecentProject(dir, store.config.name)
router.push('/editor') router.push('/editor')
} catch (err) { } catch (err) {
alert('创建失败: ' + err.message) await window.api.dialog.showMessage({
type: 'error',
title: '创建失败',
message: err.message
})
} }
} }
@@ -80,7 +82,11 @@ async function openProject(dir) {
addRecentProject(dir, store.config.name) addRecentProject(dir, store.config.name)
router.push('/editor') router.push('/editor')
} catch (err) { } catch (err) {
alert('打开失败: ' + err.message) await window.api.dialog.showMessage({
type: 'error',
title: '打开失败',
message: err.message
})
} }
} }

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>

View File

@@ -21,8 +21,8 @@
<div class="sidebar-header"> <div class="sidebar-header">
<h3>资源文件</h3> <h3>资源文件</h3>
<div class="sidebar-actions"> <div class="sidebar-actions">
<button class="btn btn-sm btn-secondary" @click="importFromDir" title="导入文件夹">&#128193;</button> <button class="btn btn-sm btn-secondary" @click="importFromDir" title="文件夹导入整个网站">&#128193; 路径</button>
<button class="btn btn-sm btn-secondary" @click="importFromFiles" title="导入文件">&#128196;</button> <button class="btn btn-sm btn-secondary" @click="importFromFiles" title="选择单个文件导入">&#128196; 文件</button>
</div> </div>
</div> </div>
<div class="file-tree-container"> <div class="file-tree-container">
@@ -32,8 +32,9 @@
@delete="handleDelete" @delete="handleDelete"
/> />
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>暂无资源</p> <div class="empty-icon">&#128193;</div>
<p class="hint">点击上方按钮导入网站文件</p> <p>尚未设置资源路径</p>
<p class="hint">点击上方路径按钮选择你的网站目录导入</p>
</div> </div>
</div> </div>
</aside> </aside>
@@ -74,7 +75,11 @@ async function importFromFiles() {
} }
async function handleDelete(relativePath) { async function handleDelete(relativePath) {
if (confirm(`确定删除 "${relativePath}"`)) { const ok = await window.api.dialog.showConfirm({
title: '删除确认',
message: `确定要删除 "${relativePath}" 吗?`
})
if (ok) {
await store.removeFile(relativePath) await store.removeFile(relativePath)
} }
} }
@@ -182,6 +187,12 @@ onBeforeUnmount(() => {
padding: 8px; padding: 8px;
} }
.empty-icon {
font-size: 48px;
opacity: 0.3;
margin-bottom: 8px;
}
.hint { .hint {
font-size: 12px; font-size: 12px;
} }

View File

@@ -13,8 +13,8 @@
## 2. 核心流程 ## 2. 核心流程
``` ```
用户准备网站资源 → 导入 Web2App → 配置应用信息 → 打包生成安装包 新建/打开项目 → 设置资源路径(导入网站目录) → 配置应用信息(名称/图标/入口等)
分发安装包 → 目标机安装 → 启动 → 独立的桌面应用运行 打包生成安装包 → 目标机安装 → 启动 → 独立的桌面应用运行
``` ```
--- ---
@@ -25,23 +25,22 @@
| 编号 | 功能 | 说明 | | 编号 | 功能 | 说明 |
|------|------|------| |------|------|------|
| F1.1 | 导入 HTML 文件 | 支持单个或批量导入自动检测入口文件index.html | | F1.1 | 设置资源路径 | 用户选择网站目录,自动导入目录下所有文件 |
| F1.2 | 导入 JS 文件 | 保留目录结构导入 | | F1.2 | 单个文件导入 | 支持选择单个或多个文件导入 |
| F1.3 | 导入 CSS 文件 | 保留目录结构导入 | | F1.3 | 目录结构保留 | 导入时保留原始目录结构 |
| F1.4 | 导入资源文件 | 图片、字体、音视频、JSON 等静态资源 | | F1.4 | 资源树展示 | 以目录树形式展示已导入的所有文件 |
| F1.5 | 资源树展示 | 以目录树形式展示已导入的所有文件 | | F1.5 | 资源增删改 | 支持在界面中删除已导入的文件 |
| F1.6 | 资源增删改 | 支持在界面中删除或替换已导入的文件 | | F1.6 | 后端代码导入 | 导入后端脚本(如 Node.js/Express 路由文件 |
| F1.7 | 后端代码导入 | 导入后端脚本(如 Node.js/Express 路由文件) |
### 3.2 应用配置 ### 3.2 应用配置
| 编号 | 功能 | 说明 | | 编号 | 功能 | 说明 |
|------|------|------| |------|------|------|
| F2.1 | 应用名称 | 设置生成的桌面应用名称 | | F2.1 | 应用名称 | 设置生成的桌面应用名称(默认为文件夹名) |
| F2.2 | 应用图标 | 支持导入自定义图标(.ico / .png | | F2.2 | 应用图标 | 支持导入自定义图标(.ico / .png / .icns |
| F2.3 | 应用版本号 | 设置版本号 | | F2.3 | 应用版本号 | 设置版本号,默认 1.0.0 |
| F2.4 | 窗口设置 | 配置窗口默认尺寸、最小尺寸、是否可调整大小 | | F2.4 | 窗口设置 | 配置窗口默认尺寸、最小尺寸、是否可调整大小 |
| F2.5 | 入口文件 | 指定网站入口 HTML 文件 | | F2.5 | 入口文件 | 指定网站入口 HTML 文件(在资源中选择) |
| F2.6 | 后端入口 | 指定后端服务入口文件(如 server.js | | F2.6 | 后端入口 | 指定后端服务入口文件(如 server.js |
| F2.7 | 作者/版权信息 | 设置应用版权与作者信息 | | F2.7 | 作者/版权信息 | 设置应用版权与作者信息 |
@@ -96,12 +95,12 @@
| 层级 | 技术选型 | 说明 | | 层级 | 技术选型 | 说明 |
|------|----------|------| |------|----------|------|
| 桌面框架 | Electron | 跨平台桌面应用框架,内置 Chromium 与 Node.js | | 桌面框架 | Electron 28 | 跨平台桌面应用框架,内置 Chromium 与 Node.js |
| 前端 UI | React / Vue 3 | Web2App 自身的图形界面(推荐 Vue 3 | | 前端 UI | Vue 3 + Vue Router + Pinia | Web2App 自身的图形界面 |
| 打包工具 | electron-builder | 将用户资源+运行时打包为安装包 | | 构建工具 | Vite 5 | 前端构建与开发服务器 |
| 打包工具 | electron-builder + NSIS | 将用户资源+运行时打包为安装包 |
| 后端引擎 | Node.js (Electron 内置) | 使用 Electron 自带的 Node.js 运行时执行用户后端代码 | | 后端引擎 | Node.js (Electron 内置) | 使用 Electron 自带的 Node.js 运行时执行用户后端代码 |
| 数据库 | better-sqlite3 | 嵌入式数据库,无需配置 | | 安装包生成 | electron-builder / NSIS | 跨平台安装包生成(.exe / .dmg / .AppImage |
| 安装包生成 | electron-builder / NSIS | 跨平台安装包生成 |
### 架构示意 ### 架构示意
@@ -129,10 +128,12 @@
### 6.1 主界面布局 ### 6.1 主界面布局
- **左侧**资源管理面板(目录树) - **首页**新建项目(选择文件夹即创建)/ 打开已有项目 / 最近项目列表
- **中间**:文件预览 / 编辑区域 - **项目编辑页**
- **侧**应用配置面板 - **侧**资源文件面板(目录树 + 导入按钮)
- **底部**打包进度与日志 - **右侧**应用配置面板(名称/版本/图标/窗口/入口等)
- **打包页**:打包配置、启动打包、实时进度与日志
- **帮助页**:使用说明与版本信息
### 6.2 主要页面 ### 6.2 主要页面