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:
@@ -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>
|
||||
Reference in New Issue
Block a user