iOSBuildServer/frontend/src/views/HistoryView.vue
shen 6f4f625c56 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
2026-06-06 17:42:27 +08:00

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">&times;</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>