Compare commits
7 Commits
95116e2338
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e143eebee7 | ||
|
|
5e890108a1 | ||
|
|
afdfce6502 | ||
|
|
482a79f1c7 | ||
|
|
90d3404cd8 | ||
|
|
8dc102a7cb | ||
|
|
686d94e178 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.web2app/
|
.web2app/
|
||||||
release/
|
release/
|
||||||
|
installer/
|
||||||
|
__appImage-x64/
|
||||||
|
|
||||||
# Binaries & executables
|
# Binaries & executables
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -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": {
|
||||||
|
|||||||
@@ -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>© 2026 Web2App. MIT License.</span>
|
<span>© 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>
|
||||||
|
|
||||||
|
|||||||
100
src/components/InputDialog.vue
Normal file
100
src/components/InputDialog.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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">☰</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">打开已有的 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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">✓</div>
|
<div class="result-icon">✓</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">✗</div>
|
<div class="result-icon error">✗</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>
|
||||||
|
|||||||
@@ -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="导入文件夹">📁</button>
|
<button class="btn btn-sm btn-secondary" @click="importFromDir" title="从文件夹导入整个网站">📁 路径</button>
|
||||||
<button class="btn btn-sm btn-secondary" @click="importFromFiles" title="导入文件">📄</button>
|
<button class="btn btn-sm btn-secondary" @click="importFromFiles" title="选择单个文件导入">📄 文件</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">📁</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;
|
||||||
}
|
}
|
||||||
|
|||||||
45
需求文档.md
45
需求文档.md
@@ -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 主要页面
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user