feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化
- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败 - 日志保存到独立 backend/logs/ 目录,不随构建目录删除 - 新增任务删除 API 及前端删除按钮 - 构建流程增加 pod install 步骤 - 通过文件传递配置避免命令行参数截断 - Provisioning Profile 支持按名称搜索 - dSYM 下载支持目录打包为 zip - 前端端口可配置、历史页增加下载列和耗时显示 - 日志弹窗支持展开/收起详细日志 - 自动生成 upload_key 标识
This commit is contained in:
@@ -352,7 +352,8 @@ const refreshTasks = async () => {
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
// 日志时间戳是本地时间,任务时间字段是 UTC
|
||||
const d = t.length > 15 && !t.endsWith('Z') && !t.includes('+') ? new Date(t + 'Z') : new Date(t)
|
||||
return d.toLocaleTimeString()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,18 @@
|
||||
<th>打包类型</th>
|
||||
<th>Scheme</th>
|
||||
<th>状态</th>
|
||||
<th>下载地址</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.id">
|
||||
<td>{{ formatTime(task.created_at) }}</td>
|
||||
<td>
|
||||
{{ formatTime(task.created_at) }}
|
||||
<span v-if="task.status === 'completed' || task.status === 'failed'" class="duration-text">
|
||||
{{ formatDuration(task.started_at, task.completed_at) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.app_name }}</td>
|
||||
<td>
|
||||
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
||||
@@ -46,16 +52,24 @@
|
||||
{{ errorCategoryLabel(task.error_category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="download-cell">
|
||||
<template v-if="task.status === 'completed' && task.oss_url">
|
||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
||||
@click="task._showQr = !task._showQr">
|
||||
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</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.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||
<button v-if="task.status === 'completed' && 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>
|
||||
<button v-if="task.status !== 'running' && task.status !== 'pending'" class="action-btn btn-danger" @click="deleteTask(task.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredTasks.length">
|
||||
<td colspan="6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
<td colspan="7" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -97,15 +111,23 @@
|
||||
<span class="meta-value">已启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-modal-actions">
|
||||
<button class="btn-toggle-logs" @click="showVerboseLogs = !showVerboseLogs">
|
||||
{{ showVerboseLogs ? '收起日志' : '展开详细日志' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="log-modal-body" ref="logContainer">
|
||||
<div v-for="(log, i) in logLines" :key="i" :class="['log-line', log.level]">
|
||||
<template v-if="log.level === 'step'">
|
||||
<div class="step-indicator">{{ log.message }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="log-text">{{ log.message }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<template v-for="(log, i) in logLines" :key="i">
|
||||
<div v-if="showVerboseLogs || log.level === 'step' || log.level === 'error' || log.level === 'warn'"
|
||||
:class="['log-line', log.level]">
|
||||
<template v-if="log.level === 'step'">
|
||||
<div class="step-indicator">{{ log.message }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="log-text">{{ log.message }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!logLines.length" class="log-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span>暂无日志</span>
|
||||
@@ -118,28 +140,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<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>
|
||||
|
||||
@@ -149,12 +149,11 @@ import { ref, computed, onMounted, nextTick, onUnmounted } from 'vue'
|
||||
const tasks = ref([])
|
||||
const filterBuildType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const showQrModal = ref(false)
|
||||
const qrTask = ref(null)
|
||||
const showLogModal = ref(false)
|
||||
const logTask = ref(null)
|
||||
const logLines = ref([])
|
||||
const logContainer = ref(null)
|
||||
const showVerboseLogs = ref(false)
|
||||
let logWs = null
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
@@ -177,35 +176,55 @@ const viewLogs = async (taskId) => {
|
||||
if (!task) return
|
||||
logTask.value = task
|
||||
logLines.value = []
|
||||
showVerboseLogs.value = false
|
||||
showLogModal.value = true
|
||||
|
||||
if (task.status === 'running' || task.status === 'pending') {
|
||||
// 进行中的任务,连接 WebSocket 获取实时日志
|
||||
connectLogWs(taskId)
|
||||
} else {
|
||||
// 已完成/失败的任务,显示已有信息
|
||||
if (task.error_message) {
|
||||
logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` })
|
||||
}
|
||||
if (task.error_category) {
|
||||
logLines.value.push({ level: 'warn', message: `分类: ${errorCategoryLabel(task.error_category)}` })
|
||||
}
|
||||
if (task.config_json) {
|
||||
try {
|
||||
const cfg = JSON.parse(task.config_json)
|
||||
logLines.value.push({ level: 'info', message: `版本: ${cfg.VERSION} Build: ${cfg.BUILD_VERSION}` })
|
||||
logLines.value.push({ level: 'info', message: `Scheme: ${cfg.SCHEME} 类型: ${cfg.BUILD_TYPE}` })
|
||||
if (cfg.CERTIFICATE) logLines.value.push({ level: 'info', message: `证书: ${cfg.CERTIFICATE}` })
|
||||
if (cfg.BUNDLE_ID) logLines.value.push({ level: 'info', message: `BundleID: ${cfg.BUNDLE_ID}` })
|
||||
} catch {}
|
||||
}
|
||||
if (task.status === 'completed') {
|
||||
logLines.value.push({ level: 'step', message: '[打包完成]' })
|
||||
if (task.oss_url) logLines.value.push({ level: 'info', message: `下载链接: ${task.oss_url}` })
|
||||
} else if (task.status === 'failed') {
|
||||
logLines.value.push({ level: 'step', message: '[打包失败]' })
|
||||
} else if (task.status === 'cancelled') {
|
||||
logLines.value.push({ level: 'info', message: '任务已取消' })
|
||||
// 已完成/失败的任务,先从 REST API 获取完整日志
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/log`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.log) {
|
||||
const lines = data.log.split('\n').filter(Boolean)
|
||||
logLines.value = lines.map(line => {
|
||||
const m = line.match(/^\[(.+?)\]\s+\[(.+?)\]\s+(.*)$/)
|
||||
if (m) {
|
||||
return { timestamp: m[1], level: m[2].toLowerCase(), message: m[3] }
|
||||
}
|
||||
return { timestamp: '', level: 'info', message: line }
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// 日志为空时,回退到元数据展示
|
||||
if (!logLines.value.length) {
|
||||
if (task.error_message) {
|
||||
logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` })
|
||||
}
|
||||
if (task.error_category) {
|
||||
logLines.value.push({ level: 'warn', message: `分类: ${errorCategoryLabel(task.error_category)}` })
|
||||
}
|
||||
if (task.config_json) {
|
||||
try {
|
||||
const cfg = JSON.parse(task.config_json)
|
||||
logLines.value.push({ level: 'info', message: `版本: ${cfg.VERSION} Build: ${cfg.BUILD_VERSION}` })
|
||||
logLines.value.push({ level: 'info', message: `Scheme: ${cfg.SCHEME} 类型: ${cfg.BUILD_TYPE}` })
|
||||
if (cfg.CERTIFICATE) logLines.value.push({ level: 'info', message: `证书: ${cfg.CERTIFICATE}` })
|
||||
if (cfg.BUNDLE_ID) logLines.value.push({ level: 'info', message: `BundleID: ${cfg.BUNDLE_ID}` })
|
||||
} catch {}
|
||||
}
|
||||
if (task.status === 'completed') {
|
||||
logLines.value.push({ level: 'step', message: '[打包完成]' })
|
||||
if (task.oss_url) logLines.value.push({ level: 'info', message: `下载链接: ${task.oss_url}` })
|
||||
} else if (task.status === 'failed') {
|
||||
logLines.value.push({ level: 'step', message: '[打包失败]' })
|
||||
} else if (task.status === 'cancelled') {
|
||||
logLines.value.push({ level: 'info', message: '任务已取消' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,27 +265,33 @@ 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 deleteTask = async (taskId) => {
|
||||
if (!confirm('确定要删除这条打包记录吗?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
|
||||
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString()
|
||||
const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z')
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (started, completed) => {
|
||||
if (!started || !completed) return ''
|
||||
const start = started.endsWith('Z') || started.includes('+') ? new Date(started) : new Date(started + 'Z')
|
||||
const end = completed.endsWith('Z') || completed.includes('+') ? new Date(completed) : new Date(completed + 'Z')
|
||||
const seconds = Math.floor((end - start) / 1000)
|
||||
if (seconds < 0) return ''
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}分${remainSeconds}秒`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainMinutes = minutes % 60
|
||||
return `${hours}时${remainMinutes}分`
|
||||
}
|
||||
|
||||
const statusText = (s) => {
|
||||
@@ -283,6 +308,8 @@ const errorCategoryLabel = (cat) => {
|
||||
git: '代码拉取',
|
||||
config: '配置问题',
|
||||
build: '构建配置',
|
||||
timeout: '打包超时',
|
||||
upload: '上传失败',
|
||||
unknown: '未知错误',
|
||||
}
|
||||
return map[cat] || '未知错误'
|
||||
@@ -298,17 +325,26 @@ const errorCategoryLabel = (cat) => {
|
||||
.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 { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table th:nth-child(1) { width: 16%; }
|
||||
.config-table th:nth-child(2) { width: 18%; }
|
||||
.config-table th:nth-child(3) { width: 10%; }
|
||||
.config-table th:nth-child(4) { width: 12%; }
|
||||
.config-table th:nth-child(5) { width: 12%; }
|
||||
.config-table th:nth-child(6) { width: 18%; }
|
||||
.config-table th:nth-child(7) { width: 14%; }
|
||||
.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-btns { white-space: normal; }
|
||||
.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
|
||||
.download-cell { white-space: normal; }
|
||||
.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; }
|
||||
|
||||
@@ -363,6 +399,22 @@ const errorCategoryLabel = (cat) => {
|
||||
.meta-label { font-size: 12px; color: #999; }
|
||||
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
||||
|
||||
.duration-text {
|
||||
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 日志操作栏 */
|
||||
.log-modal-actions {
|
||||
padding: 8px 20px; background: #161b22; border-bottom: 1px solid #30363d;
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
.btn-toggle-logs {
|
||||
background: transparent; border: 1px solid #30363d; color: #58a6ff;
|
||||
padding: 4px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-toggle-logs:hover { background: #1f2937; border-color: #58a6ff; }
|
||||
|
||||
/* 日志内容区 */
|
||||
.log-modal-body {
|
||||
flex: 1; min-height: 0; max-height: 56vh; overflow-y: auto;
|
||||
@@ -399,16 +451,11 @@ const errorCategoryLabel = (cat) => {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.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; }
|
||||
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; }
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.text-muted { color: #999; font-size: 13px; }
|
||||
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
|
||||
.qr-thumb:hover { border-color: #1890ff; }
|
||||
.btn-danger { color: #ff4d4f; border-color: #ff4d4f; }
|
||||
.btn-danger:hover { background: #ff4d4f; color: white; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user