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

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>