feat: 完成 Web2App 核心功能实现
- Electron 主进程:窗口管理、IPC、项目读写、打包引擎 - Vue 3 前端:4 个视图页面、3 个组件、Pinia 状态管理 - 生成应用模板:Express 后端 + Electron 窗口 - 支持资源导入、应用配置、跨平台打包
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user