Files
iOSBuildServer/frontend/src/views/BuildView.vue
T

527 lines
20 KiB
Vue

<template>
<div class="container">
<div class="build-page">
<!-- 左侧配置面板 -->
<div class="config-panel">
<h2>打包配置</h2>
<div class="form-group">
<label>选择服务器环境</label>
<select v-model="selectedServer">
<option value="">请选择...</option>
<option v-for="server in serverEnvironments" :key="server" :value="server">
{{ server }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id" :disabled="!selectedServer">
<option value="">{{ selectedServer ? '请选择...' : '请先选择服务器环境' }}</option>
<option v-for="[id, app] in filteredApps" :key="id" :value="id">
{{ app.name }}
</option>
</select>
</div>
<div class="form-group">
<label>打包类型</label>
<select v-model="form.build_type">
<option value="" disabled>请选择...</option>
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
{{ buildType }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 Scheme</label>
<select v-model="form.scheme_id">
<option value="" disabled>请选择...</option>
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
{{ scheme.displayName || 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>
<p v-if="submitNotice" class="submit-notice" role="status" aria-live="polite">{{ submitNotice }}</p>
<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>
<div class="log-actions">
<button class="btn-toggle-logs" @click="showVerboseLogs = !showVerboseLogs">
{{ showVerboseLogs ? '收起日志' : '展开详细日志' }}
</button>
<button v-if="currentTaskId" class="btn btn-danger" style="width: auto; padding: 6px 12px;" @click="cancelTask">
取消任务
</button>
</div>
</div>
<div class="log-content" ref="logContainer">
<template v-for="(log, i) in logs" :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>
[{{ formatTime(log.timestamp) }}] {{ log.message }}
</template>
</div>
</template>
<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_category" class="result-row error-category-row">
<span class="result-label">失败原因</span>
<span class="error-hint">{{ errorCategoryHint(completedTask.error_category) }}</span>
</div>
<div v-if="completedTask.error_message" class="result-row">
<span class="result-label">错误详情</span>
<details class="error-details">
<summary>查看详细错误信息</summary>
<pre class="error-detail">{{ completedTask.error_message }}</pre>
</details>
</div>
<div v-if="completedTask.build_type === 'App_Store' && 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.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
<img :src="completedTask.qr_code_path" 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 { computed, ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { useRoute } from 'vue-router'
const getToken = inject('getToken')
const authFetch = (url, options = {}) => {
const token = getToken()
if (token) {
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` }
}
return fetch(url, options)
}
const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tasks = ref([])
const selectedServer = ref('')
const serverEnvironments = computed(() => {
return [...new Set(Object.values(apps.value).map(app => app.server).filter(Boolean))]
})
const filteredApps = computed(() => {
return Object.entries(apps.value).filter(([, app]) => app.server === selectedServer.value)
})
const selectedApp = computed(() => apps.value[form.value.app_id] || null)
const availableBuildTypes = computed(() => {
const certificates = selectedApp.value?.certificates || {}
return ['Ad_Hoc', 'App_Store'].filter(buildType => Boolean(certificates[buildType]))
})
const filteredSchemes = computed(() => {
const allowedNames = selectedApp.value?.allowed_scheme_names || []
return Object.entries(schemes.value).filter(([, scheme]) => {
return !allowedNames.length || allowedNames.includes(scheme.name)
})
})
const form = ref({
app_id: '',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: 'main',
})
const submitting = ref(false)
const submitNotice = ref('')
const currentTaskId = ref(null)
const completedTask = ref(null)
const logs = ref([])
const logContainer = ref(null)
const showVerboseLogs = ref(false)
const route = useRoute()
let activeWs = null
let skipNextConnect = false
const fetchTaskDetail = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}`)
if (res.ok) {
completedTask.value = await res.json()
}
} catch (e) {
// ignore
}
}
const fetchBuildLog = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/log`)
if (res.ok) {
const data = await res.json()
if (data.log) {
const lines = data.log.split('\n').filter(Boolean)
logs.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 (e) {
// ignore
}
}
const connectWs = (taskId) => {
if (activeWs) {
activeWs.close()
activeWs = null
}
showVerboseLogs.value = false
if (!taskId) {
logs.value = []
completedTask.value = null
return
}
// 从历史页跳转时跳过连接(日志已在 onMounted 中填充)
if (skipNextConnect) {
skipNextConnect = false
return
}
logs.value = []
completedTask.value = null
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const token = getToken()
if (!token) return
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
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()
}
}
}
ws.onclose = () => {
// WebSocket 关闭后,尝试从服务器获取完整日志
if (logs.value.length === 0) {
fetchBuildLog(taskId)
}
}
}
watch(() => currentTaskId.value, (newId) => {
connectWs(newId)
})
watch(selectedServer, () => {
form.value.app_id = ''
})
watch(() => form.value.app_id, (appId) => {
if (!appId) {
form.value.scheme_id = ''
form.value.build_type = ''
return
}
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
form.value.build_type = availableBuildTypes.value[0] || ''
})
onUnmounted(() => {
if (activeWs) {
activeWs.close()
activeWs = null
}
})
onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
authFetch('/api/apps'),
authFetch('/api/schemes'),
authFetch('/api/branches'),
authFetch('/api/tasks'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
branches.value = await branchesRes.json()
const allTasks = await tasksRes.json()
// 从历史页跳转过来时,读取 taskId 查询参数
const queryTaskId = route.query.taskId
if (queryTaskId) {
const task = allTasks.find(t => t.id === queryTaskId)
if (task) {
// 已完成/失败的任务,直接显示结果
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
completedTask.value = task
await fetchBuildLog(queryTaskId)
if (!logs.value.length) {
logs.value = [{ timestamp: task.completed_at || task.created_at, level: task.status === 'failed' ? 'error' : 'step', message: task.status === 'failed' ? `打包失败: ${task.error_message || ''}` : '打包完成' }]
}
}
// 设置 currentTaskId(触发 watch → connectWs,用 flag 跳过)
skipNextConnect = true
currentTaskId.value = queryTaskId
}
}
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 (submitting.value) return
if (!form.value.app_id) {
alert('请选择 App')
return
}
submitting.value = true
try {
const res = await authFetch('/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)
submitNotice.value = '打包任务已创建,正在排队,请勿重复点击。'
selectedServer.value = ''
form.value = {
app_id: '',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: branches.value[0] || 'main',
}
} else {
submitNotice.value = '提交失败,请检查配置后重试。'
}
} catch (e) {
submitNotice.value = '提交失败,请检查网络后重试。'
} 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 authFetch(`/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 authFetch('/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 '-'
// 日志时间戳是本地时间,任务时间字段是 UTC
const d = t.length > 15 && !t.endsWith('Z') && !t.includes('+') ? new Date(t + 'Z') : new Date(t)
return d.toLocaleTimeString()
}
const statusText = (s) => {
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
return map[s] || s
}
const errorCategoryHint = (cat) => {
const map = {
certificate: '签名证书问题:证书过期、未安装或不匹配',
provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在',
config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因',
}
return map[cat] || map.unknown
}
</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; }
.submit-notice { margin: 0 0 12px; padding: 9px 12px; border-radius: 6px; background: #e6f7ff; color: #096dd9; font-size: 13px; line-height: 1.5; }
.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; }
.log-actions { display: flex; align-items: center; gap: 8px; }
.btn-toggle-logs {
background: none; border: 1px solid #555; color: #a0a0a0;
padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
.btn-toggle-logs:hover { border-color: #1890ff; color: #1890ff; }
.step-indicator {
background: rgba(24, 144, 255, 0.1); border-left: 3px solid #1890ff;
padding: 6px 12px; margin: 4px 0; border-radius: 0 4px 4px 0;
font-weight: 600; color: #1890ff; font-size: 13px;
}
.error-category-row { background: #fff2f0; border-radius: 6px; padding: 8px 12px; margin: 4px 0; }
.error-hint { color: #ff4d4f; font-size: 13px; font-weight: 500; }
.error-details { font-size: 13px; }
.error-details summary { color: #1890ff; cursor: pointer; font-size: 12px; }
.error-detail {
background: #fafafa; padding: 8px; border-radius: 4px; margin-top: 6px;
font-family: 'Monaco', 'Menlo', monospace; font-size: 11px; color: #666;
max-height: 150px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
}
</style>