feat: 完成 Web2App 核心功能实现
- Electron 主进程:窗口管理、IPC、项目读写、打包引擎 - Vue 3 前端:4 个视图页面、3 个组件、Pinia 状态管理 - 生成应用模板:Express 后端 + Electron 窗口 - 支持资源导入、应用配置、跨平台打包
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.web2app/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
231
electron/builder.js
Normal file
231
electron/builder.js
Normal 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
163
electron/main.js
Normal 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
30
electron/preload.js
Normal 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
186
electron/project.js
Normal 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
|
||||||
|
}
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Web2App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5574
package-lock.json
generated
Normal file
5574
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "web2app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "将网站转化为独立运行的桌面应用",
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
|
"electron:build": "vite build && electron-builder --config",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.web2app.app",
|
||||||
|
"productName": "Web2App",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"electron/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"template/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"extraResources": [],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": null
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"shortcutName": "Web2App"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": ["dmg"],
|
||||||
|
"icon": null
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": ["AppImage"],
|
||||||
|
"icon": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"concurrently": "^8.2.0",
|
||||||
|
"electron": "^28.0.0",
|
||||||
|
"electron-builder": "^24.9.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"wait-on": "^7.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/App.vue
Normal file
8
src/App.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app-container">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
278
src/components/ConfigPanel.vue
Normal file
278
src/components/ConfigPanel.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="config-panel">
|
||||||
|
<h3>应用配置</h3>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>基本信息</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">应用名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.name"
|
||||||
|
@input="update('name', $event.target.value)"
|
||||||
|
placeholder="输入应用名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">版本号</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.version"
|
||||||
|
@input="update('version', $event.target.value)"
|
||||||
|
placeholder="1.0.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">作者</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.author"
|
||||||
|
@input="update('author', $event.target.value)"
|
||||||
|
placeholder="作者名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">描述</label>
|
||||||
|
<textarea
|
||||||
|
:value="store.config.description"
|
||||||
|
@input="update('description', $event.target.value)"
|
||||||
|
placeholder="应用描述"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">版权信息</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.copyright"
|
||||||
|
@input="update('copyright', $event.target.value)"
|
||||||
|
placeholder="Copyright © 2024"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>入口设置</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">前端入口文件</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.entry"
|
||||||
|
@input="update('entry', $event.target.value)"
|
||||||
|
placeholder="index.html"
|
||||||
|
/>
|
||||||
|
<span class="hint">相对于资源目录的入口 HTML 文件路径</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">后端入口文件</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="store.config.backendEntry"
|
||||||
|
@input="update('backendEntry', $event.target.value)"
|
||||||
|
placeholder="server.js"
|
||||||
|
/>
|
||||||
|
<span class="hint">如无后端可留空。支持 Express 风格 server.js</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>窗口设置</h4>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">窗口宽度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="store.config.windowWidth"
|
||||||
|
@input="update('windowWidth', Number($event.target.value))"
|
||||||
|
min="400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">窗口高度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="store.config.windowHeight"
|
||||||
|
@input="update('windowHeight', Number($event.target.value))"
|
||||||
|
min="300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">最小宽度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="store.config.minWidth"
|
||||||
|
@input="update('minWidth', Number($event.target.value))"
|
||||||
|
min="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">最小高度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="store.config.minHeight"
|
||||||
|
@input="update('minHeight', Number($event.target.value))"
|
||||||
|
min="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="store.config.resizable"
|
||||||
|
@change="update('resizable', $event.target.checked)"
|
||||||
|
/>
|
||||||
|
允许调整窗口大小
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>应用图标</h4>
|
||||||
|
|
||||||
|
<div class="icon-area">
|
||||||
|
<div class="icon-preview" v-if="store.config.icon">
|
||||||
|
<span class="icon-label">已选择图标</span>
|
||||||
|
<span class="icon-name">{{ store.config.icon }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-preview empty" v-else>
|
||||||
|
<span>未选择图标</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="selectIcon">
|
||||||
|
选择图标
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">支持 .ico、.png、.icns 格式</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useProjectStore } from '../stores/project'
|
||||||
|
|
||||||
|
const store = useProjectStore()
|
||||||
|
|
||||||
|
function update(key, value) {
|
||||||
|
store.updateConfig({ [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectIcon() {
|
||||||
|
const iconPath = await window.api.dialog.selectIcon()
|
||||||
|
if (iconPath) {
|
||||||
|
await store.setIcon(iconPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-panel {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="checkbox"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview.empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
color: var(--text);
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-name {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
src/components/PackProgress.vue
Normal file
145
src/components/PackProgress.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<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>
|
||||||
172
src/components/ResourceTree.vue
Normal file
172
src/components/ResourceTree.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<div class="resource-tree">
|
||||||
|
<div
|
||||||
|
v-for="item in files"
|
||||||
|
:key="item.path"
|
||||||
|
class="tree-item"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tree-node"
|
||||||
|
:class="{ expanded: expanded.has(item.path) }"
|
||||||
|
@click="toggleExpand(item)"
|
||||||
|
>
|
||||||
|
<span class="tree-toggle" v-if="item.type === 'directory'">
|
||||||
|
{{ expanded.has(item.path) ? '▼' : '▶' }}
|
||||||
|
</span>
|
||||||
|
<span class="tree-toggle placeholder" v-else></span>
|
||||||
|
<span class="tree-icon">{{ getIcon(item) }}</span>
|
||||||
|
<span class="tree-name">{{ item.name }}</span>
|
||||||
|
<span v-if="item.type === 'file'" class="tree-size">{{ formatSize(item.size) }}</span>
|
||||||
|
<button
|
||||||
|
class="tree-delete"
|
||||||
|
title="删除"
|
||||||
|
@click.stop="confirmDelete(item)"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.type === 'directory' && expanded.has(item.path)" class="tree-children">
|
||||||
|
<ResourceTree
|
||||||
|
v-if="item.children"
|
||||||
|
:files="item.children"
|
||||||
|
@delete="(p) => emit('delete', p)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
files: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete'])
|
||||||
|
const expanded = ref(new Set())
|
||||||
|
|
||||||
|
function toggleExpand(item) {
|
||||||
|
if (item.type === 'directory') {
|
||||||
|
const set = new Set(expanded.value)
|
||||||
|
if (set.has(item.path)) {
|
||||||
|
set.delete(item.path)
|
||||||
|
} else {
|
||||||
|
set.add(item.path)
|
||||||
|
expandParents(item.path, set)
|
||||||
|
}
|
||||||
|
expanded.value = set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandParents(path, set) {
|
||||||
|
const parts = path.replace(/\\/g, '/').split('/')
|
||||||
|
for (let i = 1; i <= parts.length; i++) {
|
||||||
|
set.add(parts.slice(0, i).join('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(item) {
|
||||||
|
emit('delete', item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(item) {
|
||||||
|
if (item.type === 'directory') return '📁'
|
||||||
|
const extMap = {
|
||||||
|
'.html': '⌨',
|
||||||
|
'.htm': '⌨',
|
||||||
|
'.css': '🎨',
|
||||||
|
'.js': '⚙',
|
||||||
|
'.json': '{',
|
||||||
|
'.png': '📷',
|
||||||
|
'.jpg': '📷',
|
||||||
|
'.jpeg': '📷',
|
||||||
|
'.gif': '📷',
|
||||||
|
'.svg': '📷',
|
||||||
|
'.ico': '📷',
|
||||||
|
'.woff': '👄',
|
||||||
|
'.woff2': '👄',
|
||||||
|
'.ttf': '👄'
|
||||||
|
}
|
||||||
|
return extMap[item.ext] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + 'B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resource-tree {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 12px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-toggle.placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-delete {
|
||||||
|
opacity: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node:hover .tree-delete {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node:hover .tree-delete:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/main.js
Normal file
10
src/main.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
31
src/router/index.js
Normal file
31
src/router/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('../views/Home.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/editor',
|
||||||
|
name: 'ProjectEditor',
|
||||||
|
component: () => import('../views/ProjectEditor.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pack',
|
||||||
|
name: 'PackPage',
|
||||||
|
component: () => import('../views/PackPage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/help',
|
||||||
|
name: 'Help',
|
||||||
|
component: () => import('../views/Help.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
46
src/stores/packaging.js
Normal file
46
src/stores/packaging.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
130
src/stores/project.js
Normal file
130
src/stores/project.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useProjectStore = defineStore('project', {
|
||||||
|
state: () => ({
|
||||||
|
projectPath: null,
|
||||||
|
config: {
|
||||||
|
name: '',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: '',
|
||||||
|
author: '',
|
||||||
|
entry: 'index.html',
|
||||||
|
backendEntry: '',
|
||||||
|
windowWidth: 1200,
|
||||||
|
windowHeight: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
icon: '',
|
||||||
|
copyright: ''
|
||||||
|
},
|
||||||
|
files: [],
|
||||||
|
isDirty: false
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
hasProject: (state) => !!state.projectPath,
|
||||||
|
projectName: (state) => state.config.name || '未命名项目'
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async createProject(name, dir) {
|
||||||
|
const result = await window.api.project.create(name, dir)
|
||||||
|
this.projectPath = result.projectPath
|
||||||
|
this.config = result.config
|
||||||
|
this.files = []
|
||||||
|
this.isDirty = false
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadProject(projectPath) {
|
||||||
|
const result = await window.api.project.load(projectPath)
|
||||||
|
this.projectPath = result.projectPath
|
||||||
|
this.config = result.config
|
||||||
|
this.files = result.files
|
||||||
|
this.isDirty = false
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveProject() {
|
||||||
|
if (!this.projectPath) return
|
||||||
|
const updated = await window.api.project.save(this.projectPath, this.config)
|
||||||
|
this.config = updated
|
||||||
|
this.isDirty = false
|
||||||
|
},
|
||||||
|
|
||||||
|
async importFiles(filePaths) {
|
||||||
|
if (!this.projectPath) return
|
||||||
|
const files = await window.api.project.importFiles(this.projectPath, filePaths)
|
||||||
|
this.files = files
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFile(relativePath) {
|
||||||
|
if (!this.projectPath) return
|
||||||
|
const files = await window.api.project.removeFile(this.projectPath, relativePath)
|
||||||
|
this.files = files
|
||||||
|
},
|
||||||
|
|
||||||
|
async setIcon(iconPath) {
|
||||||
|
if (!this.projectPath) return
|
||||||
|
const config = await window.api.project.setIcon(this.projectPath, iconPath)
|
||||||
|
this.config = config
|
||||||
|
},
|
||||||
|
|
||||||
|
async importFromDirectory() {
|
||||||
|
const dirPath = await window.api.dialog.selectDirectory()
|
||||||
|
if (!dirPath) return
|
||||||
|
const tree = await window.api.dir.readTree(dirPath)
|
||||||
|
const filesToImport = collectFiles(tree)
|
||||||
|
if (filesToImport.length > 0) {
|
||||||
|
await this.importFiles(filesToImport)
|
||||||
|
}
|
||||||
|
return filesToImport.length
|
||||||
|
},
|
||||||
|
|
||||||
|
async importFromFiles() {
|
||||||
|
const paths = await window.api.dialog.selectFile()
|
||||||
|
if (!paths || paths.length === 0) return
|
||||||
|
await this.importFiles(paths)
|
||||||
|
return paths.length
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfig(partial) {
|
||||||
|
this.config = { ...this.config, ...partial }
|
||||||
|
this.isDirty = true
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.projectPath = null
|
||||||
|
this.config = {
|
||||||
|
name: '',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: '',
|
||||||
|
author: '',
|
||||||
|
entry: 'index.html',
|
||||||
|
backendEntry: '',
|
||||||
|
windowWidth: 1200,
|
||||||
|
windowHeight: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
icon: '',
|
||||||
|
copyright: ''
|
||||||
|
}
|
||||||
|
this.files = []
|
||||||
|
this.isDirty = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function collectFiles(tree) {
|
||||||
|
const result = []
|
||||||
|
for (const item of tree) {
|
||||||
|
if (item.type === 'file') {
|
||||||
|
result.push(item.path)
|
||||||
|
} else if (item.children) {
|
||||||
|
result.push(...collectFiles(item.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
148
src/style.css
Normal file
148
src/style.css
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--primary-hover: #4338ca;
|
||||||
|
--primary-light: #eef2ff;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .icon {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
203
src/views/Help.vue
Normal file
203
src/views/Help.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="help-page">
|
||||||
|
<header class="help-header">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="goHome">← 返回</button>
|
||||||
|
<h2>使用帮助</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="help-content">
|
||||||
|
<section class="help-section">
|
||||||
|
<h3>什么是 Web2App?</h3>
|
||||||
|
<p>Web2App 是一个桌面工具,可以将完整的网站(包括前端和后端)打包成独立的桌面应用程序。用户只需导入网站资源文件,配置应用信息,即可一键生成安装包。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="help-section">
|
||||||
|
<h3>使用步骤</h3>
|
||||||
|
<div class="step-list">
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">1</span>
|
||||||
|
<div>
|
||||||
|
<strong>新建项目</strong>
|
||||||
|
<p>在首页点击"新建项目",选择项目保存位置并输入项目名称。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">2</span>
|
||||||
|
<div>
|
||||||
|
<strong>导入资源</strong>
|
||||||
|
<p>在项目编辑页,点击"导入文件夹"或"导入文件"按钮,选择网站的资源文件。支持 HTML、JS、CSS、图片等各类文件。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>配置应用</strong>
|
||||||
|
<p>填写应用名称、版本号、窗口尺寸等信息。可选择应用图标。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">4</span>
|
||||||
|
<div>
|
||||||
|
<strong>打包生成</strong>
|
||||||
|
<p>点击"打包应用",选择输出位置,开始打包。生成独立的安装包文件。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">5</span>
|
||||||
|
<div>
|
||||||
|
<strong>安装运行</strong>
|
||||||
|
<p>在目标计算机上运行生成的安装包,按照向导完成安装。启动应用即可使用。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="help-section">
|
||||||
|
<h3>后端支持</h3>
|
||||||
|
<p>如果你的网站包含后端,请在导入时包含 <code>server.js</code> 文件。Web2App 会在生成的应用中自动启动后端服务。</p>
|
||||||
|
<p>后端 server.js 示例:</p>
|
||||||
|
<pre><code>module.exports = function(app) {
|
||||||
|
app.get('/api/hello', (req, res) => {
|
||||||
|
res.json({ message: 'Hello from Web2App!' })
|
||||||
|
})
|
||||||
|
}</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="help-section">
|
||||||
|
<h3>注意事项</h3>
|
||||||
|
<ul>
|
||||||
|
<li>首次打包需要下载依赖,请保持网络畅通</li>
|
||||||
|
<li>生成的安装包体积约 50-80MB(含 Electron 运行时)</li>
|
||||||
|
<li>支持 Windows、macOS、Linux 平台</li>
|
||||||
|
<li>确保网站入口文件名为 index.html</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="help-section">
|
||||||
|
<h3>版本信息</h3>
|
||||||
|
<p>Web2App v1.0.0</p>
|
||||||
|
<p>Electron + Vue 3</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
function goHome() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section code {
|
||||||
|
background: var(--primary-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section pre {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
235
src/views/Home.vue
Normal file
235
src/views/Home.vue
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<div class="home-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">◆</span>
|
||||||
|
<h1>Web2App</h1>
|
||||||
|
</div>
|
||||||
|
<p class="subtitle">将网站转化为独立运行的桌面应用</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-actions">
|
||||||
|
<div class="action-card" @click="createNewProject">
|
||||||
|
<span class="action-icon">+</span>
|
||||||
|
<span class="action-title">新建项目</span>
|
||||||
|
<span class="action-desc">创建新的 Web2App 项目</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card" @click="openExistingProject">
|
||||||
|
<span class="action-icon">☰</span>
|
||||||
|
<span class="action-title">打开项目</span>
|
||||||
|
<span class="action-desc">打开已有的 .web2app 项目</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card" @click="goHelp">
|
||||||
|
<span class="action-icon">?</span>
|
||||||
|
<span class="action-title">使用帮助</span>
|
||||||
|
<span class="action-desc">查看使用说明与版本信息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentProjects.length > 0" class="recent-section">
|
||||||
|
<h3>最近项目</h3>
|
||||||
|
<div class="recent-list">
|
||||||
|
<div
|
||||||
|
v-for="proj in recentProjects"
|
||||||
|
:key="proj.path"
|
||||||
|
class="recent-item"
|
||||||
|
@click="openProject(proj.path)"
|
||||||
|
>
|
||||||
|
<span class="recent-name">{{ proj.name }}</span>
|
||||||
|
<span class="recent-path">{{ proj.path }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useProjectStore } from '../stores/project'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useProjectStore()
|
||||||
|
const recentProjects = ref(loadRecentProjects())
|
||||||
|
|
||||||
|
async function createNewProject() {
|
||||||
|
const dir = await window.api.dialog.selectDirectory()
|
||||||
|
if (!dir) return
|
||||||
|
const name = prompt('请输入项目名称:')
|
||||||
|
if (!name) return
|
||||||
|
try {
|
||||||
|
await store.createProject(name, dir)
|
||||||
|
addRecentProject(dir, name)
|
||||||
|
router.push('/editor')
|
||||||
|
} catch (err) {
|
||||||
|
alert('创建失败: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExistingProject() {
|
||||||
|
const dir = await window.api.dialog.selectDirectory()
|
||||||
|
if (!dir) return
|
||||||
|
await openProject(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProject(dir) {
|
||||||
|
try {
|
||||||
|
await store.loadProject(dir)
|
||||||
|
addRecentProject(dir, store.config.name)
|
||||||
|
router.push('/editor')
|
||||||
|
} catch (err) {
|
||||||
|
alert('打开失败: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHelp() {
|
||||||
|
router.push('/help')
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentProjects() {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem('web2app-recent')
|
||||||
|
return data ? JSON.parse(data) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecentProject(path, name) {
|
||||||
|
const list = loadRecentProjects()
|
||||||
|
const idx = list.findIndex(p => p.path === path)
|
||||||
|
if (idx >= 0) list.splice(idx, 1)
|
||||||
|
list.unshift({ path, name, time: Date.now() })
|
||||||
|
if (list.length > 10) list.pop()
|
||||||
|
localStorage.setItem('web2app-recent', JSON.stringify(list))
|
||||||
|
recentProjects.value = list
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30px 40px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-section h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-item:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
src/views/PackPage.vue
Normal file
255
src/views/PackPage.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pack-page">
|
||||||
|
<header class="pack-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="goBack">← 返回编辑</button>
|
||||||
|
<h2>打包应用</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pack-body">
|
||||||
|
<div class="pack-config card">
|
||||||
|
<h3>打包配置</h3>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">应用名称</span>
|
||||||
|
<span>{{ store.config.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">版本号</span>
|
||||||
|
<span>{{ store.config.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">资源文件数</span>
|
||||||
|
<span>{{ fileCount }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">输出路径</label>
|
||||||
|
<div class="output-selector">
|
||||||
|
<input type="text" :value="outputPath || '默认位置'" readonly />
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="selectOutputPath">选择</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">目标平台</span>
|
||||||
|
<span>{{ platformLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!packStore.isPacking && !packStore.result" class="pack-actions">
|
||||||
|
<button class="btn btn-primary btn-start" @click="startPack" :disabled="!store.hasProject">
|
||||||
|
开始打包
|
||||||
|
</button>
|
||||||
|
<p class="pack-hint">打包过程可能需要几分钟,请耐心等待</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PackProgress
|
||||||
|
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>
|
||||||
|
<h3>打包完成!</h3>
|
||||||
|
<p>安装包已生成</p>
|
||||||
|
<p v-if="packStore.result.outputPath" class="output-path">{{ packStore.result.outputPath }}</p>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="btn btn-secondary" @click="goBack">返回编辑</button>
|
||||||
|
<button class="btn btn-primary" @click="startPack">重新打包</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="packStore.result && !packStore.result.success" class="result-error card">
|
||||||
|
<div class="result-icon error">✗</div>
|
||||||
|
<h3>打包失败</h3>
|
||||||
|
<p>{{ packStore.result.error }}</p>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="btn btn-secondary" @click="goBack">返回编辑</button>
|
||||||
|
<button class="btn btn-primary" @click="startPack">重试</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useProjectStore } from '../stores/project'
|
||||||
|
import { usePackagingStore } from '../stores/packaging'
|
||||||
|
import PackProgress from '../components/PackProgress.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useProjectStore()
|
||||||
|
const packStore = usePackagingStore()
|
||||||
|
const outputPath = ref(null)
|
||||||
|
|
||||||
|
if (!store.hasProject) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = computed(() => countFiles(store.files))
|
||||||
|
|
||||||
|
const platformLabel = computed(() => {
|
||||||
|
const p = process.platform
|
||||||
|
if (p === 'win32') return 'Windows (.exe)'
|
||||||
|
if (p === 'darwin') return 'macOS (.dmg)'
|
||||||
|
if (p === 'linux') return 'Linux (.AppImage)'
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectOutputPath() {
|
||||||
|
const ext = process.platform === 'win32' ? 'exe' : process.platform === 'darwin' ? 'dmg' : 'AppImage'
|
||||||
|
const path = await window.api.dialog.saveFile({
|
||||||
|
defaultPath: `${store.config.name}-Setup.${ext}`,
|
||||||
|
filters: [{ name: '安装包', extensions: [ext] }]
|
||||||
|
})
|
||||||
|
if (path) outputPath.value = path
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPack() {
|
||||||
|
packStore.startPacking(store.projectPath, outputPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
packStore.reset()
|
||||||
|
router.push('/editor')
|
||||||
|
}
|
||||||
|
|
||||||
|
function countFiles(files) {
|
||||||
|
let count = 0
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.type === 'file') count++
|
||||||
|
if (f.children) count += countFiles(f.children)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!packStore.result) {
|
||||||
|
packStore.reset()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pack-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-config {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-config h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-selector input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 12px 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-success, .result-error {
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
194
src/views/ProjectEditor.vue
Normal file
194
src/views/ProjectEditor.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editor">
|
||||||
|
<header class="editor-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="goHome">← 返回</button>
|
||||||
|
<h2>{{ store.projectName }}</h2>
|
||||||
|
<span v-if="store.isDirty" class="dirty-badge">未保存</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="saveProject" :disabled="!store.isDirty">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="goPack">
|
||||||
|
打包应用 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>资源文件</h3>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="importFromDir" title="导入文件夹">📁</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="importFromFiles" title="导入文件">📄</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-tree-container">
|
||||||
|
<ResourceTree
|
||||||
|
v-if="store.files.length > 0"
|
||||||
|
:files="store.files"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>暂无资源</p>
|
||||||
|
<p class="hint">点击上方按钮导入网站文件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<ConfigPanel />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useProjectStore } from '../stores/project'
|
||||||
|
import ResourceTree from '../components/ResourceTree.vue'
|
||||||
|
import ConfigPanel from '../components/ConfigPanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useProjectStore()
|
||||||
|
|
||||||
|
if (!store.hasProject) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject() {
|
||||||
|
await store.saveProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFromDir() {
|
||||||
|
const count = await store.importFromDirectory()
|
||||||
|
if (count > 0) store.isDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFromFiles() {
|
||||||
|
const count = await store.importFromFiles()
|
||||||
|
if (count > 0) store.isDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(relativePath) {
|
||||||
|
if (confirm(`确定删除 "${relativePath}"?`)) {
|
||||||
|
await store.removeFile(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
if (store.isDirty) {
|
||||||
|
store.saveProject()
|
||||||
|
}
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPack() {
|
||||||
|
if (store.isDirty) {
|
||||||
|
store.saveProject()
|
||||||
|
}
|
||||||
|
router.push('/pack')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (store.isDirty) {
|
||||||
|
store.saveProject()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirty-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--warning);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
120
template/main.js
Normal file
120
template/main.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const express = require('express')
|
||||||
|
const cors = require('cors')
|
||||||
|
|
||||||
|
let mainWindow
|
||||||
|
let tray
|
||||||
|
let server
|
||||||
|
|
||||||
|
const FRONTEND_DIR = path.join(__dirname, 'frontend')
|
||||||
|
const BACKEND_DIR = path.join(__dirname, 'backend')
|
||||||
|
const ICON_PATH = path.join(__dirname, 'icon.png')
|
||||||
|
|
||||||
|
function startBackend() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const serverApp = express()
|
||||||
|
serverApp.use(cors())
|
||||||
|
serverApp.use(express.json())
|
||||||
|
|
||||||
|
const backendEntry = path.join(BACKEND_DIR, 'server.js')
|
||||||
|
if (fs.existsSync(backendEntry)) {
|
||||||
|
try {
|
||||||
|
const userApi = require(backendEntry)
|
||||||
|
if (typeof userApi === 'function') {
|
||||||
|
userApi(serverApp)
|
||||||
|
} else if (userApi && typeof userApi.setup === 'function') {
|
||||||
|
userApi.setup(serverApp)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('后端加载失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server = serverApp.listen(0, () => {
|
||||||
|
const port = server.address().port
|
||||||
|
console.log(`后端服务运行于端口: ${port}`)
|
||||||
|
resolve(port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(port) {
|
||||||
|
const icon = fs.existsSync(ICON_PATH) ? nativeImage.createFromPath(ICON_PATH) : undefined
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
icon,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
},
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const indexPath = path.join(FRONTEND_DIR, 'index.html')
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
mainWindow.loadFile(indexPath)
|
||||||
|
} else {
|
||||||
|
mainWindow.loadURL(`http://localhost:${port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (tray) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTray() {
|
||||||
|
const iconPath = path.join(__dirname, 'tray-icon.png')
|
||||||
|
const icon = fs.existsSync(iconPath)
|
||||||
|
? nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 })
|
||||||
|
: nativeImage.createEmpty()
|
||||||
|
|
||||||
|
tray = new Tray(icon)
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{ label: '显示', click: () => mainWindow.show() },
|
||||||
|
{ label: '退出', click: () => { app.isQuitting = true; app.quit() } }
|
||||||
|
])
|
||||||
|
tray.setToolTip('Web2App Generated Application')
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
tray.on('click', () => mainWindow.show())
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const port = await startBackend()
|
||||||
|
createWindow(port)
|
||||||
|
setupTray()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('启动失败:', err)
|
||||||
|
createWindow(3000)
|
||||||
|
setupTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow(3000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
app.isQuitting = true
|
||||||
|
if (server) server.close()
|
||||||
|
})
|
||||||
20
template/package.json
Normal file
20
template/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "generated-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Generated by Web2App",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"build": "electron-builder",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"better-sqlite3": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.0.0",
|
||||||
|
"electron-builder": "^24.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
template/preload.js
Normal file
9
template/preload.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
platform: process.platform,
|
||||||
|
versions: {
|
||||||
|
node: process.versions.node,
|
||||||
|
electron: process.versions.electron
|
||||||
|
}
|
||||||
|
})
|
||||||
20
vite.config.js
Normal file
20
vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
165
需求文档.md
Normal file
165
需求文档.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Web2App 需求文档
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
**项目名称**:Web2App
|
||||||
|
**目标用户**:普通用户(提供图形界面操作)
|
||||||
|
**目标平台**:Windows / macOS / Linux(跨平台)
|
||||||
|
|
||||||
|
**项目目标**:开发一款桌面应用程序,允许用户导入完整的网站资源(HTML、JS、CSS、图片等),并将其打包为独立安装包。安装后生成一个可独立运行的桌面应用,内置网站的前端界面与后端服务,无需用户额外配置运行环境。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户准备网站资源 → 导入 Web2App → 配置应用信息 → 打包生成安装包
|
||||||
|
→ 分发安装包 → 目标机安装 → 启动 → 独立的桌面应用运行
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 功能需求
|
||||||
|
|
||||||
|
### 3.1 导入与管理网站资源
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F1.1 | 导入 HTML 文件 | 支持单个或批量导入,自动检测入口文件(index.html) |
|
||||||
|
| F1.2 | 导入 JS 文件 | 保留目录结构导入 |
|
||||||
|
| F1.3 | 导入 CSS 文件 | 保留目录结构导入 |
|
||||||
|
| F1.4 | 导入资源文件 | 图片、字体、音视频、JSON 等静态资源 |
|
||||||
|
| F1.5 | 资源树展示 | 以目录树形式展示已导入的所有文件 |
|
||||||
|
| F1.6 | 资源增删改 | 支持在界面中删除或替换已导入的文件 |
|
||||||
|
| F1.7 | 后端代码导入 | 导入后端脚本(如 Node.js/Express 路由文件) |
|
||||||
|
|
||||||
|
### 3.2 应用配置
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F2.1 | 应用名称 | 设置生成的桌面应用名称 |
|
||||||
|
| F2.2 | 应用图标 | 支持导入自定义图标(.ico / .png) |
|
||||||
|
| F2.3 | 应用版本号 | 设置版本号 |
|
||||||
|
| F2.4 | 窗口设置 | 配置窗口默认尺寸、最小尺寸、是否可调整大小 |
|
||||||
|
| F2.5 | 入口文件 | 指定网站入口 HTML 文件 |
|
||||||
|
| F2.6 | 后端入口 | 指定后端服务入口文件(如 server.js) |
|
||||||
|
| F2.7 | 作者/版权信息 | 设置应用版权与作者信息 |
|
||||||
|
|
||||||
|
### 3.3 打包生成安装包
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F3.1 | 打包为安装程序 | 将所有资源打包为一个安装包文件 |
|
||||||
|
| F3.2 | 自动嵌入运行时 | 打包时自动嵌入 Electron 运行时,无需目标机预装 |
|
||||||
|
| F3.3 | 跨平台打包 | 支持在对应平台生成目标平台的安装包 |
|
||||||
|
| F3.4 | 安装包格式 | Windows 生成 .exe,macOS 生成 .dmg,Linux 生成 .AppImage 或 .deb |
|
||||||
|
| F3.5 | 进度显示 | 打包过程显示实时进度与日志 |
|
||||||
|
| F3.6 | 完整性校验 | 打包完成后生成校验值(SHA256) |
|
||||||
|
|
||||||
|
### 3.4 安装与运行
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F4.1 | 安装引导 | 安装包提供标准的安装向导界面 |
|
||||||
|
| F4.2 | 安装路径选择 | 用户可自定义安装目录 |
|
||||||
|
| F4.3 | 桌面快捷方式 | 安装时可选创建桌面快捷方式 |
|
||||||
|
| F4.4 | 自动启动后端 | 应用启动时自动运行内置后端服务 |
|
||||||
|
| F4.5 | 独立运行 | 生成的 .exe 完全独立运行,不依赖外部 Web 服务器 |
|
||||||
|
| F4.6 | 系统托盘 | 支持最小化到系统托盘运行 |
|
||||||
|
|
||||||
|
### 3.5 后端服务
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F5.1 | 内置服务器 | 应用内嵌 HTTP 服务器,启动时自动监听本地端口 |
|
||||||
|
| F5.2 | 后端代码执行 | 支持运行用户导入的后端脚本(基于 Node.js) |
|
||||||
|
| F5.3 | API 路由 | 支持自定义 API 路由 |
|
||||||
|
| F5.4 | 数据持久化 | 内置 SQLite 数据库支持(无需额外安装) |
|
||||||
|
| F5.5 | 端口管理 | 自动检测端口占用并分配可用端口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 非功能需求
|
||||||
|
|
||||||
|
| 编号 | 需求 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| NF1 | 易用性 | 全图形化操作,无需命令行 |
|
||||||
|
| NF2 | 安装包体积 | 生成的安装包应尽量精简(预计基础体积约 50-80MB,含 Electron 运行时) |
|
||||||
|
| NF3 | 启动速度 | 应用冷启动时间不超过 3 秒 |
|
||||||
|
| NF4 | 安全性 | 打包过程不向外网发送任何数据;安装包可校验完整性 |
|
||||||
|
| NF5 | 兼容性 | Windows 10+ / macOS 11+ / Ubuntu 20.04+ |
|
||||||
|
| NF6 | 离线可用 | Web2App 自身打包功能完全离线可用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术方案
|
||||||
|
|
||||||
|
| 层级 | 技术选型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 桌面框架 | Electron | 跨平台桌面应用框架,内置 Chromium 与 Node.js |
|
||||||
|
| 前端 UI | React / Vue 3 | Web2App 自身的图形界面(推荐 Vue 3) |
|
||||||
|
| 打包工具 | electron-builder | 将用户资源+运行时打包为安装包 |
|
||||||
|
| 后端引擎 | Node.js (Electron 内置) | 使用 Electron 自带的 Node.js 运行时执行用户后端代码 |
|
||||||
|
| 数据库 | better-sqlite3 | 嵌入式数据库,无需配置 |
|
||||||
|
| 安装包生成 | electron-builder / NSIS | 跨平台安装包生成 |
|
||||||
|
|
||||||
|
### 架构示意
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 生成的桌面应用 (Electron) │
|
||||||
|
│ ┌───────────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ 前端 (用户网站) │ │ 后端服务 │ │
|
||||||
|
│ │ HTML/JS/CSS │ │ Node.js 脚本 │ │
|
||||||
|
│ │ Chromium 渲染 │ │ Express 路由 │ │
|
||||||
|
│ └────────┬──────────┘ │ SQLite 数据库 │ │
|
||||||
|
│ │ └────────┬───────┘ │
|
||||||
|
│ └──────────┬───────────┘ │
|
||||||
|
│ HTTP 本地通信 │
|
||||||
|
│ ┌────────────────────────────────────────┐ │
|
||||||
|
│ │ Electron 主进程 │ │
|
||||||
|
│ │ (窗口管理 / 系统托盘 / 自动启动后端) │ │
|
||||||
|
│ └────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 用户界面(Web2App 自身)
|
||||||
|
|
||||||
|
### 6.1 主界面布局
|
||||||
|
|
||||||
|
- **左侧**:资源管理面板(目录树)
|
||||||
|
- **中间**:文件预览 / 编辑区域
|
||||||
|
- **右侧**:应用配置面板
|
||||||
|
- **底部**:打包进度与日志
|
||||||
|
|
||||||
|
### 6.2 主要页面
|
||||||
|
|
||||||
|
| 页面 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 首页 | 新建项目 / 打开已有项目 |
|
||||||
|
| 项目编辑页 | 资源导入与管理、应用配置 |
|
||||||
|
| 打包页面 | 打包配置、启动打包、查看日志 |
|
||||||
|
| 帮助页 | 使用说明与版本信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交付物
|
||||||
|
|
||||||
|
1. Web2App 桌面应用程序(安装包)
|
||||||
|
2. 项目源代码
|
||||||
|
3. 需求文档(本文档)
|
||||||
|
4. 用户使用手册
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 开发路线(建议)
|
||||||
|
|
||||||
|
| 阶段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| Phase 1 | Electron 项目骨架搭建、资源导入与管理功能 |
|
||||||
|
| Phase 2 | 应用配置界面、后端运行引擎集成 |
|
||||||
|
| Phase 3 | 打包流程实现(electron-builder 集成) |
|
||||||
|
| Phase 4 | 安装包生成、跨平台适配 |
|
||||||
|
| Phase 5 | 测试、性能优化、文档编写 |
|
||||||
Reference in New Issue
Block a user