feat: 完成 Web2App 核心功能实现

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

8
src/App.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<div id="app-container">
<router-view />
</div>
</template>
<script setup>
</script>

View 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>

View 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>

View 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) ? '&#9660;' : '&#9654;' }}
</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)"
>&times;</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 '&#128193;'
const extMap = {
'.html': '&#9000;',
'.htm': '&#9000;',
'.css': '&#127912;',
'.js': '&#9881;',
'.json': '&#123;',
'.png': '&#128247;',
'.jpg': '&#128247;',
'.jpeg': '&#128247;',
'.gif': '&#128247;',
'.svg': '&#128247;',
'.ico': '&#128247;',
'.woff': '&#128068;',
'.woff2': '&#128068;',
'.ttf': '&#128068;'
}
return extMap[item.ext] || '&#128196;'
}
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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,203 @@
<template>
<div class="help-page">
<header class="help-header">
<button class="btn btn-secondary btn-sm" @click="goHome">&larr; 返回</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>在项目编辑页点击"导入文件夹""导入文件"按钮选择网站的资源文件支持 HTMLJSCSS图片等各类文件</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>支持 WindowsmacOSLinux 平台</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
View File

@@ -0,0 +1,235 @@
<template>
<div class="home">
<div class="home-header">
<div class="logo">
<span class="logo-icon">&#9670;</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">&#9776;</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
View 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">&larr; 返回编辑</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">&#10003;</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">&#10007;</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
View 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">&larr; 返回</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">
打包应用 &rarr;
</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="导入文件夹">&#128193;</button>
<button class="btn btn-sm btn-secondary" @click="importFromFiles" title="导入文件">&#128196;</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>