Initial commit: iOS Build Server

- FastAPI backend with build queue, WebSocket logs, task management
- Vue 3 frontend with build/config/history views
- Xcode project build automation with IPA export
- Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
shen
2026-06-06 17:42:27 +08:00
commit 6f4f625c56
51 changed files with 9968 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="container">
<div class="build-page">
<!-- 左侧配置面板 -->
<div class="config-panel">
<h2>打包配置</h2>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id">
<option value="">请选择...</option>
<option v-for="(app, id) in apps" :key="id" :value="id">
{{ app.server }} - {{ app.name }}
</option>
</select>
</div>
<div class="form-group">
<label>打包类型</label>
<select v-model="form.build_type">
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
</select>
</div>
<div class="form-group">
<label>选择 Scheme</label>
<select v-model="form.scheme_id">
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
{{ scheme.name }}
</option>
</select>
</div>
<div class="form-group">
<label>代码分支</label>
<select v-model="form.branch">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
</select>
</div>
<div class="form-group">
<label>代码混淆</label>
<div class="checkbox-group">
<input type="checkbox" id="obfuscation" v-model="form.obfuscation">
<label for="obfuscation">启用混淆</label>
</div>
</div>
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
{{ submitting ? '提交中...' : '开始打包' }}
</button>
</div>
<!-- 右侧日志和任务列表 -->
<div class="right-panel">
<div class="log-panel">
<div class="log-header">
<h3>实时日志 {{ currentTaskId ? `- ${currentTaskId}` : '' }}</h3>
<button v-if="currentTaskId" class="btn btn-danger" style="width: auto; padding: 6px 12px;" @click="cancelTask">
取消任务
</button>
</div>
<div class="log-content" ref="logContainer">
<div v-for="(log, i) in logs" :key="i" :class="['log-line', log.level]">
[{{ formatTime(log.timestamp) }}] {{ log.message }}
</div>
<div v-if="!logs.length && !currentTaskId" class="log-line info" style="color: #666;">
选择任务或创建新任务查看日志
</div>
<div v-else-if="!logs.length" class="log-line info" style="color: #666;">
正在连接日志流...
</div>
</div>
</div>
<!-- 打包结果 -->
<div v-if="completedTask" class="result-panel">
<h3>打包结果</h3>
<div class="result-info">
<div class="result-row">
<span class="result-label">App</span>
<span>{{ completedTask.app_name }}</span>
</div>
<div class="result-row">
<span class="result-label">类型</span>
<span>{{ completedTask.build_type }}</span>
</div>
<div class="result-row">
<span class="result-label">状态</span>
<span :class="['task-status', `status-${completedTask.status}`]">{{ statusText(completedTask.status) }}</span>
</div>
<div v-if="completedTask.error_message" class="result-row">
<span class="result-label">错误信息</span>
<span class="error-msg">{{ completedTask.error_message }}</span>
</div>
<div v-if="completedTask.oss_url" class="result-row">
<span class="result-label">下载链接</span>
<a :href="completedTask.oss_url" target="_blank" class="download-link">点击下载 IPA</a>
</div>
</div>
<div v-if="completedTask.qr_code_path" class="qr-section">
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
<p class="qr-hint">扫码下载安装</p>
</div>
</div>
<div class="task-list">
<h3>任务队列</h3>
<div v-for="task in tasks" :key="task.id" class="task-item" @click="selectTask(task.id)">
<div class="task-info">
<div class="app-name">{{ task.app_name }}</div>
<div class="task-meta">{{ task.build_type }} | {{ task.scheme_name }} | {{ formatTime(task.created_at) }}</div>
</div>
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
</div>
<div v-if="!tasks.length" style="text-align: center; color: #999; padding: 20px;">
暂无任务
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tasks = ref([])
const form = ref({
app_id: '',
build_type: 'Ad_Hoc',
scheme_id: '1',
obfuscation: false,
branch: 'main',
})
const submitting = ref(false)
const currentTaskId = ref(null)
const completedTask = ref(null)
const logs = ref([])
const logContainer = ref(null)
let activeWs = null
const fetchTaskDetail = async (taskId) => {
try {
const res = await fetch(`/api/tasks/${taskId}`)
if (res.ok) {
completedTask.value = await res.json()
}
} catch (e) {
// ignore
}
}
const connectWs = (taskId) => {
if (activeWs) {
activeWs.close()
activeWs = null
}
logs.value = []
completedTask.value = null
if (!taskId) return
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
activeWs = ws
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.level !== 'heartbeat') {
logs.value.push(msg)
nextTick(() => {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
})
// 检测打包完成或失败,获取任务详情
if (msg.level === 'step' && msg.message.includes('打包完成')) {
fetchTaskDetail(taskId)
refreshTasks()
}
if (msg.level === 'error' || (msg.level === 'step' && msg.message.includes('打包失败'))) {
fetchTaskDetail(taskId)
refreshTasks()
}
}
}
}
watch(() => currentTaskId.value, (newId) => {
connectWs(newId)
})
onUnmounted(() => {
if (activeWs) {
activeWs.close()
activeWs = null
}
})
onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
fetch('/api/apps'),
fetch('/api/schemes'),
fetch('/api/branches'),
fetch('/api/tasks'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
branches.value = await branchesRes.json()
const allTasks = await tasksRes.json()
tasks.value = allTasks.filter(t =>
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
)
// 确保默认分支在列表中
if (!branches.value.includes(form.value.branch)) {
form.value.branch = branches.value[0] || 'main'
}
})
const submitTask = async () => {
if (!form.value.app_id) {
alert('请选择 App')
return
}
submitting.value = true
try {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
if (res.ok) {
const task = await res.json()
currentTaskId.value = task.id
tasks.value.unshift(task)
} else {
alert('提交失败')
}
} catch (e) {
alert('提交失败')
} finally {
submitting.value = false
}
}
const selectTask = (taskId) => {
currentTaskId.value = taskId
completedTask.value = null
}
const cancelTask = async () => {
if (!currentTaskId.value) return
if (!confirm('确定取消当前任务?')) return
const res = await fetch(`/api/tasks/${currentTaskId.value}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
alert(err.detail || '取消失败')
}
refreshTasks()
// 刷新任务详情以更新状态
if (completedTask.value?.id === currentTaskId.value) {
fetchTaskDetail(currentTaskId.value)
}
}
const refreshTasks = async () => {
const res = await fetch('/api/tasks?limit=50')
const all = await res.json()
// 显示未完成的任务 + 当前选中的任务
tasks.value = all.filter(t =>
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
)
}
const formatTime = (t) => {
if (!t) return '-'
const d = new Date(t)
return d.toLocaleTimeString()
}
const statusText = (s) => {
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
return map[s] || s
}
</script>
<style scoped>
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
.build-page { display: grid; grid-template-columns: 360px 1fr; gap: 24px; }
.config-panel { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.config-panel h2 { font-size: 16px; margin-bottom: 16px; color: #1a1a2e; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
.form-group select, .form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
.form-group select:focus { outline: none; border-color: #1890ff; }
.checkbox-group { display: flex; align-items: center; gap: 8px; }
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; }
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
.btn-primary { background: #1890ff; color: white; }
.btn-primary:hover { background: #40a9ff; }
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-danger { background: #ff4d4f; color: white; }
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
.log-panel { background: #1a1a2e; border-radius: 12px; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.log-header { padding: 12px 16px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; }
.log-header h3 { color: #a0a0a0; font-size: 14px; }
.log-content { flex: 1; padding: 16px; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; color: #00ff00; overflow-y: auto; line-height: 1.6; }
.log-line { margin-bottom: 4px; }
.log-line.info { color: #00ff00; }
.log-line.warn { color: #ffcc00; }
.log-line.error { color: #ff4d4f; }
.log-line.step { color: #1890ff; font-weight: bold; }
.task-list { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-height: 300px; overflow-y: auto; }
.task-list h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
.task-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
.task-item:hover { border-color: #1890ff; }
.task-info { flex: 1; }
.task-info .app-name { font-weight: 500; color: #1a1a2e; }
.task-info .task-meta { font-size: 12px; color: #999; margin-top: 4px; }
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.status-pending { background: #f0f0f0; color: #666; }
.status-running { background: #e6f7ff; color: #1890ff; }
.status-completed { background: #f6ffed; color: #52c41a; }
.status-failed { background: #fff2f0; color: #ff4d4f; }
.status-cancelled { background: #f0f0f0; color: #999; }
.result-panel { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.result-panel h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
.result-info { margin-bottom: 16px; }
.result-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
.result-row:last-child { border-bottom: none; }
.result-label { width: 80px; color: #999; font-size: 13px; flex-shrink: 0; }
.download-link { color: #1890ff; text-decoration: none; }
.download-link:hover { text-decoration: underline; }
.qr-section { text-align: center; padding: 16px; background: #fafafa; border-radius: 8px; }
.qr-image { width: 180px; height: 180px; border: 1px solid #f0f0f0; border-radius: 8px; }
.qr-hint { margin-top: 8px; font-size: 13px; color: #999; }
.error-msg { color: #ff4d4f; font-size: 13px; word-break: break-all; }
</style>