feat: 完成 Web2App 核心功能实现

- Electron 主进程:窗口管理、IPC、项目读写、打包引擎
- Vue 3 前端:4 个视图页面、3 个组件、Pinia 状态管理
- 生成应用模板:Express 后端 + Electron 窗口
- 支持资源导入、应用配置、跨平台打包
This commit is contained in:
gmh01
2026-07-23 18:25:35 +08:00
parent b67e27beb1
commit 2f3ec636e0
26 changed files with 8455 additions and 0 deletions

231
electron/builder.js Normal file
View File

@@ -0,0 +1,231 @@
const fs = require('fs')
const path = require('path')
const { execSync, spawn } = require('child_process')
const { getConfigPath, getSrcDir } = require('./project')
let isCancelled = false
function cancelPack() {
isCancelled = true
}
async function startPack(projectPath, outputPath, onProgress) {
isCancelled = false
const config = JSON.parse(fs.readFileSync(getConfigPath(projectPath), 'utf-8'))
const srcDir = getSrcDir(projectPath)
const tempDir = path.join(projectPath, '.web2app', 'temp-build')
onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 })
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'prepare', message: '复制模板文件...', percent: 10 })
copyTemplateSync(tempDir)
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 })
const frontendDest = path.join(tempDir, 'frontend')
fs.mkdirSync(frontendDest, { recursive: true })
copyDirContentsSync(srcDir, frontendDest)
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 })
const templatePackagePath = path.join(tempDir, 'package.json')
const templatePackage = JSON.parse(fs.readFileSync(templatePackagePath, 'utf-8'))
templatePackage.name = config.name.toLowerCase().replace(/\s+/g, '-')
templatePackage.productName = config.name
templatePackage.version = config.version
templatePackage.description = config.description || `Generated by Web2App - ${config.name}`
templatePackage.author = config.author || ''
templatePackage.build = {
appId: `com.web2app.${templatePackage.name}`,
productName: config.name,
artifactName: `${config.name}-Setup-\${version}.\${ext}`,
directories: {
output: 'dist'
},
files: [
'main.js',
'preload.js',
'frontend/**/*',
'backend/**/*',
'node_modules/**/*'
],
extraResources: config.icon ? [
{
from: path.join(frontendDest, config.icon),
to: 'icon.png'
}
] : [],
win: {
target: [
{
target: 'nsis',
arch: ['x64']
}
],
icon: config.icon ? path.join(frontendDest, config.icon) : undefined
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
shortcutName: config.name
},
mac: {
target: ['dmg'],
icon: config.icon ? path.join(frontendDest, config.icon) : undefined
},
linux: {
target: ['AppImage'],
icon: config.icon ? path.join(frontendDest, config.icon) : undefined
}
}
fs.writeFileSync(templatePackagePath, JSON.stringify(templatePackage, null, 2))
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'install', message: '安装依赖 (首次较慢)...', percent: 50 })
try {
execSync('npm.cmd install --production', {
cwd: tempDir,
stdio: 'pipe',
timeout: 300000
})
} catch (err) {
onProgress({ stage: 'install', message: `依赖安装失败: ${err.message}`, percent: 50 })
return { success: false, error: err.message }
}
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'build', message: '正在打包生成安装包...', percent: 65 })
return new Promise((resolve) => {
const builderPath = path.join(tempDir, 'node_modules', '.bin', 'electron-builder')
const builderCmd = process.platform === 'win32' ? `${builderPath}.cmd` : builderPath
const builderProcess = spawn(builderCmd, ['--config', templatePackagePath], {
cwd: tempDir,
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
})
let buildOutput = ''
builderProcess.stdout.on('data', (data) => {
buildOutput += data.toString()
const msg = data.toString()
if (msg.includes('packaging')) {
onProgress({ stage: 'build', message: '正在打包...', percent: 75 })
} else if (msg.includes('signing')) {
onProgress({ stage: 'sign', message: '正在签名...', percent: 85 })
} else {
onProgress({ stage: 'build', message: msg.trim(), percent: 75 })
}
})
builderProcess.stderr.on('data', (data) => {
buildOutput += data.toString()
})
builderProcess.on('close', (code) => {
if (isCancelled) {
resolve({ success: false, cancelled: true })
return
}
if (code !== 0) {
const errMsg = buildOutput.split('\n').filter(l => l.includes('error')).join('\n') || '打包失败'
onProgress({ stage: 'error', message: errMsg, percent: 0 })
resolve({ success: false, error: errMsg, output: buildOutput })
return
}
onProgress({ stage: 'complete', message: '打包完成!', percent: 100 })
const distDir = path.join(tempDir, 'dist')
let installerPath = null
if (fs.existsSync(distDir)) {
const files = fs.readdirSync(distDir)
.filter(f => f.endsWith('.exe') || f.endsWith('.dmg') || f.endsWith('.AppImage'))
.sort((a, b) => fs.statSync(path.join(distDir, b)).mtimeMs - fs.statSync(path.join(distDir, a)).mtimeMs)
if (files.length > 0) {
installerPath = path.join(distDir, files[0])
if (outputPath) {
try {
fs.copyFileSync(installerPath, outputPath)
installerPath = outputPath
} catch (e) {
}
}
}
}
resolve({ success: true, outputPath: installerPath })
})
builderProcess.on('error', (err) => {
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 }

163
electron/main.js Normal file
View File

@@ -0,0 +1,163 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
const { createProject, loadProject, saveProject, importFiles, removeFile, setProjectIcon } = require('./project')
const { startPack, cancelPack } = require('./builder')
let mainWindow
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
title: 'Web2App',
show: false
})
if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
}
mainWindow.once('ready-to-show', () => {
mainWindow.show()
})
}
ipcMain.handle('dialog:selectDirectory', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]
}
return null
})
ipcMain.handle('dialog:selectFile', async (_event, options) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: options?.filters || [
{ name: '所有文件', extensions: ['*'] }
]
})
if (!result.canceled) {
return result.filePaths
}
return []
})
ipcMain.handle('dialog:selectIcon', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [
{ name: '图标文件', extensions: ['ico', 'png', 'icns'] }
]
})
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]
}
return null
})
ipcMain.handle('dialog:saveFile', async (_event, options) => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: options?.defaultPath,
filters: options?.filters || [{ name: '安装包', extensions: ['exe', 'dmg', 'AppImage'] }]
})
if (!result.canceled && result.filePath) {
return result.filePath
}
return null
})
ipcMain.handle('dir:readTree', async (_event, dirPath) => {
return readDirectoryTree(dirPath)
})
ipcMain.handle('project:create', async (_event, name, dir) => {
return createProject(name, dir)
})
ipcMain.handle('project:load', async (_event, projectPath) => {
return loadProject(projectPath)
})
ipcMain.handle('project:save', async (_event, projectPath, data) => {
return saveProject(projectPath, data)
})
ipcMain.handle('project:importFiles', async (_event, projectPath, files) => {
return importFiles(projectPath, files)
})
ipcMain.handle('project:removeFile', async (_event, projectPath, relativePath) => {
return removeFile(projectPath, relativePath)
})
ipcMain.handle('project:setIcon', async (_event, projectPath, iconPath) => {
return setProjectIcon(projectPath, iconPath)
})
ipcMain.handle('pack:start', async (_event, projectPath, outputPath) => {
const result = await startPack(projectPath, outputPath, (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pack:progress', progress)
}
})
return result
})
ipcMain.handle('pack:cancel', () => {
cancelPack()
return true
})
function readDirectoryTree(dirPath) {
const items = fs.readdirSync(dirPath, { withFileTypes: true })
const result = []
for (const item of items) {
if (item.name.startsWith('.')) continue
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
result.push({
name: item.name,
path: fullPath,
type: 'directory',
children: readDirectoryTree(fullPath)
})
} else {
result.push({
name: item.name,
path: fullPath,
type: 'file',
size: fs.statSync(fullPath).size,
ext: path.extname(item.name).toLowerCase()
})
}
}
return result.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

30
electron/preload.js Normal file
View File

@@ -0,0 +1,30 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
dialog: {
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
selectFile: (options) => ipcRenderer.invoke('dialog:selectFile', options),
selectIcon: () => ipcRenderer.invoke('dialog:selectIcon'),
saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options)
},
dir: {
readTree: (dirPath) => ipcRenderer.invoke('dir:readTree', dirPath)
},
project: {
create: (name, dir) => ipcRenderer.invoke('project:create', name, dir),
load: (projectPath) => ipcRenderer.invoke('project:load', projectPath),
save: (projectPath, data) => ipcRenderer.invoke('project:save', projectPath, data),
importFiles: (projectPath, files) => ipcRenderer.invoke('project:importFiles', projectPath, files),
removeFile: (projectPath, relativePath) => ipcRenderer.invoke('project:removeFile', projectPath, relativePath),
setIcon: (projectPath, iconPath) => ipcRenderer.invoke('project:setIcon', projectPath, iconPath)
},
pack: {
start: (projectPath, outputPath) => ipcRenderer.invoke('pack:start', projectPath, outputPath),
cancel: () => ipcRenderer.invoke('pack:cancel'),
onProgress: (callback) => {
const handler = (_event, progress) => callback(progress)
ipcRenderer.on('pack:progress', handler)
return () => ipcRenderer.removeListener('pack:progress', handler)
}
}
})

186
electron/project.js Normal file
View File

@@ -0,0 +1,186 @@
const fs = require('fs')
const path = require('path')
function getProjectDir(projectPath) {
return projectPath
}
function getSrcDir(projectPath) {
const dir = path.join(projectPath, '.web2app', 'src')
fs.mkdirSync(dir, { recursive: true })
return dir
}
function getConfigPath(projectPath) {
return path.join(projectPath, '.web2app', 'project.json')
}
function getDistDir(projectPath) {
const dir = path.join(projectPath, '.web2app', 'dist')
fs.mkdirSync(dir, { recursive: true })
return dir
}
function createProject(name, dir) {
const projectPath = path.join(dir, name)
if (fs.existsSync(projectPath)) {
throw new Error(`目录 ${projectPath} 已存在`)
}
fs.mkdirSync(path.join(projectPath, '.web2app', 'src'), { recursive: true })
fs.mkdirSync(path.join(projectPath, '.web2app', 'dist'), { recursive: true })
const config = {
name,
version: '1.0.0',
description: '',
author: '',
entry: 'index.html',
backendEntry: '',
windowWidth: 1200,
windowHeight: 800,
minWidth: 800,
minHeight: 600,
resizable: true,
icon: '',
copyright: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
fs.writeFileSync(getConfigPath(projectPath), JSON.stringify(config, null, 2))
return { projectPath, config }
}
function loadProject(projectPath) {
const configPath = getConfigPath(projectPath)
if (!fs.existsSync(configPath)) {
throw new Error('不是有效的 Web2App 项目')
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const srcDir = getSrcDir(projectPath)
const files = readFileTree(srcDir)
return { projectPath, config, files }
}
function saveProject(projectPath, data) {
const configPath = getConfigPath(projectPath)
const existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const updated = { ...existing, ...data, updatedAt: new Date().toISOString() }
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2))
return updated
}
function importFiles(projectPath, files) {
const srcDir = getSrcDir(projectPath)
const imported = []
for (const filePath of files) {
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
const destDir = path.join(srcDir, path.basename(filePath))
copyDirSync(filePath, destDir)
imported.push({ source: filePath, dest: destDir })
} else {
const destPath = path.join(srcDir, path.basename(filePath))
fs.copyFileSync(filePath, destPath)
imported.push({ source: filePath, dest: destPath })
}
}
return readFileTree(srcDir)
}
function removeFile(projectPath, relativePath) {
const srcDir = getSrcDir(projectPath)
const fullPath = path.join(srcDir, relativePath)
if (!fs.existsSync(fullPath)) {
throw new Error(`文件不存在: ${relativePath}`)
}
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true })
} else {
fs.unlinkSync(fullPath)
}
return readFileTree(srcDir)
}
function setProjectIcon(projectPath, iconPath) {
const srcDir = getSrcDir(projectPath)
const ext = path.extname(iconPath)
const destName = `app-icon${ext}`
const destPath = path.join(srcDir, destName)
fs.copyFileSync(iconPath, destPath)
const config = JSON.parse(fs.readFileSync(getConfigPath(projectPath), 'utf-8'))
config.icon = destName
fs.writeFileSync(getConfigPath(projectPath), JSON.stringify(config, null, 2))
return config
}
function readFileTree(dirPath, basePath = '') {
const items = []
if (!fs.existsSync(dirPath)) return items
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const relativePath = basePath ? path.join(basePath, entry.name) : entry.name
const fullPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
items.push({
name: entry.name,
path: relativePath,
type: 'directory',
children: readFileTree(fullPath, relativePath)
})
} else {
items.push({
name: entry.name,
path: relativePath,
type: 'file',
size: fs.statSync(fullPath).size,
ext: path.extname(entry.name).toLowerCase()
})
}
}
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
return a.name.localeCompare(b.name)
})
return items
}
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)
}
}
}
module.exports = {
createProject,
loadProject,
saveProject,
importFiles,
removeFile,
setProjectIcon,
getSrcDir,
getDistDir,
getConfigPath,
getProjectDir
}