- 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
196 lines
7.9 KiB
Vue
196 lines
7.9 KiB
Vue
<template>
|
|
<div class="container">
|
|
<div class="history-page">
|
|
<div class="header-row">
|
|
<h2>打包历史</h2>
|
|
<div class="filters">
|
|
<select v-model="filterBuildType" class="filter-select">
|
|
<option value="">全部类型</option>
|
|
<option value="Ad_Hoc">Ad_Hoc</option>
|
|
<option value="App_Store">App_Store</option>
|
|
</select>
|
|
<select v-model="filterStatus" class="filter-select">
|
|
<option value="">全部状态</option>
|
|
<option value="pending">等待中</option>
|
|
<option value="running">打包中</option>
|
|
<option value="completed">已完成</option>
|
|
<option value="failed">失败</option>
|
|
<option value="cancelled">已取消</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<table class="config-table">
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th>
|
|
<th>App</th>
|
|
<th>打包类型</th>
|
|
<th>Scheme</th>
|
|
<th>状态</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="task in filteredTasks" :key="task.id">
|
|
<td>{{ formatTime(task.created_at) }}</td>
|
|
<td>{{ task.app_name }}</td>
|
|
<td>
|
|
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
|
{{ task.build_type }}
|
|
</span>
|
|
</td>
|
|
<td>{{ task.scheme_name }}</td>
|
|
<td>
|
|
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
|
</td>
|
|
<td class="action-btns">
|
|
<button class="action-btn" @click="viewLogs(task.id)">日志</button>
|
|
<button v-if="task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
|
|
<button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
|
|
<button v-if="task.oss_url" class="action-btn" @click="downloadIpa(task.id)">下载</button>
|
|
<button v-if="task.qr_code_path" class="action-btn" @click="showQrCode(task)">二维码</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!filteredTasks.length">
|
|
<td colspan="6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 二维码弹窗 -->
|
|
<div v-if="showQrModal" class="modal-overlay" @click.self="showQrModal = false">
|
|
<div class="qr-modal">
|
|
<div class="modal-header">
|
|
<h3>下载二维码 - {{ qrTask?.app_name }}</h3>
|
|
<button class="modal-close" @click="showQrModal = false">×</button>
|
|
</div>
|
|
<div class="qr-content">
|
|
<img v-if="qrTask" :src="`/api/tasks/${qrTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
|
<div class="qr-info">
|
|
<p><strong>App:</strong> {{ qrTask?.app_name }}</p>
|
|
<p><strong>版本:</strong> {{ qrTask?.scheme_name }}</p>
|
|
<p><strong>类型:</strong> {{ qrTask?.build_type }}</p>
|
|
<p><strong>时间:</strong> {{ formatTime(qrTask?.created_at) }}</p>
|
|
</div>
|
|
<div class="qr-actions">
|
|
<button class="btn btn-primary" @click="downloadQrCode">保存二维码</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
const router = useRouter()
|
|
const tasks = ref([])
|
|
const filterBuildType = ref('')
|
|
const filterStatus = ref('')
|
|
const showQrModal = ref(false)
|
|
const qrTask = ref(null)
|
|
|
|
const filteredTasks = computed(() => {
|
|
return tasks.value.filter(task => {
|
|
// 默认隐藏失败和已取消的任务
|
|
if (!filterStatus.value && (task.status === 'failed' || task.status === 'cancelled')) return false
|
|
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
|
|
if (filterStatus.value && task.status !== filterStatus.value) return false
|
|
return true
|
|
})
|
|
})
|
|
|
|
onMounted(async () => {
|
|
const res = await fetch('/api/tasks?limit=100')
|
|
tasks.value = await res.json()
|
|
})
|
|
|
|
const viewLogs = (taskId) => {
|
|
router.push({ path: '/build', query: { taskId } })
|
|
}
|
|
|
|
const downloadDsym = (taskId) => {
|
|
window.open(`/api/tasks/${taskId}/dsym`)
|
|
}
|
|
|
|
const downloadObfMaps = (taskId) => {
|
|
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
|
}
|
|
|
|
const downloadIpa = (taskId) => {
|
|
window.open(`/api/tasks/${taskId}/ipa`)
|
|
}
|
|
|
|
const showQrCode = (task) => {
|
|
qrTask.value = task
|
|
showQrModal.value = true
|
|
}
|
|
|
|
const downloadQrCode = () => {
|
|
if (qrTask.value) {
|
|
const link = document.createElement('a')
|
|
link.href = `/api/tasks/${qrTask.value.id}/qrcode`
|
|
link.download = `${qrTask.value.app_name}_二维码.png`
|
|
link.click()
|
|
}
|
|
}
|
|
|
|
const formatTime = (t) => {
|
|
if (!t) return '-'
|
|
return new Date(t).toLocaleString()
|
|
}
|
|
|
|
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; }
|
|
.history-page { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
|
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
|
.header-row h2 { font-size: 18px; color: #1a1a2e; margin: 0; }
|
|
.filters { display: flex; gap: 12px; }
|
|
.filter-select { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; min-width: 120px; }
|
|
.filter-select:focus { outline: none; border-color: #1890ff; }
|
|
|
|
.config-table { width: 100%; border-collapse: collapse; }
|
|
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
|
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
|
.config-table td { font-size: 14px; }
|
|
.config-table tr:hover { background: #fafafa; }
|
|
|
|
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
|
.badge-adhoc { background: #e6f7ff; color: #1890ff; }
|
|
.badge-appstore { background: #f6ffed; color: #52c41a; }
|
|
|
|
.action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
|
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
|
|
|
.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; }
|
|
|
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
.qr-modal { background: white; border-radius: 12px; padding: 24px; width: 400px; }
|
|
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
|
.modal-header h3 { font-size: 16px; margin: 0; }
|
|
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
|
.qr-content { text-align: center; }
|
|
.qr-image { width: 200px; height: 200px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 16px; }
|
|
.qr-info { text-align: left; padding: 16px; background: #fafafa; border-radius: 8px; margin-bottom: 16px; }
|
|
.qr-info p { margin: 8px 0; font-size: 14px; color: #333; }
|
|
.qr-actions { display: flex; justify-content: center; }
|
|
.btn { padding: 10px 24px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
|
.btn-primary { background: #1890ff; color: white; }
|
|
.btn-primary:hover { background: #40a9ff; }
|
|
</style>
|