feat: Scheme 显示名称、打包结果按类型显示、错误分类
- Scheme 配置新增 displayName 字段,下拉框和任务列表显示友好名称 - Ad_Hoc 类型显示下载二维码,App_Store 类型显示 IPA 下载链接 - 新增 error_category 错误分类(证书/描述文件/编译/依赖等) - 打包服务增加详细错误分析和分类
This commit is contained in:
parent
6f4f625c56
commit
898ec01560
@ -23,6 +23,7 @@ def _migrate_db():
|
||||
"""简单的数据库迁移:为旧表添加缺失的列"""
|
||||
migrations = [
|
||||
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
||||
("tasks", "error_category", "VARCHAR"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
||||
@ -36,6 +36,7 @@ class Task(Base):
|
||||
|
||||
# 错误
|
||||
error_message = Column(Text)
|
||||
error_category = Column(String) # certificate/provisioning/compilation/dependency/git/config/build/unknown
|
||||
|
||||
# 配置快照
|
||||
config_json = Column(Text)
|
||||
|
||||
@ -41,7 +41,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
||||
app_name=app.get("name", ""),
|
||||
build_type=task.build_type,
|
||||
scheme_id=task.scheme_id,
|
||||
scheme_name=scheme.get("name", ""),
|
||||
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
|
||||
obfuscation=task.obfuscation,
|
||||
branch=task.branch,
|
||||
status="pending",
|
||||
|
||||
@ -34,6 +34,7 @@ class TaskResponse(BaseModel):
|
||||
qr_code_path: Optional[str]
|
||||
build_dir: Optional[str]
|
||||
error_message: Optional[str]
|
||||
error_category: Optional[str] = None
|
||||
config_json: Optional[str]
|
||||
|
||||
class Config:
|
||||
|
||||
@ -18,6 +18,67 @@ from ..config import (
|
||||
from .log_streamer import log_streamer
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
"""带分类的打包错误"""
|
||||
|
||||
def __init__(self, message: str, category: str = "unknown", detail: str = ""):
|
||||
super().__init__(message)
|
||||
self.category = category
|
||||
self.detail = detail
|
||||
|
||||
|
||||
# 错误分类规则:(关键词列表, category, 友好提示)
|
||||
_ERROR_RULES = [
|
||||
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
||||
"Code Sign error", "errSecInternalComponent", "CSSMERR_TP_NOT_TRUSTED",
|
||||
"iPhone Distribution", "iPhone Developer: no identity found"],
|
||||
"certificate", "签名证书问题:证书过期、未安装或不匹配,请检查钥匙串中的证书"),
|
||||
(["Provisioning profile", "No matching provisioning profiles",
|
||||
"embedded.mobileprovision", "PROVISIONING_PROFILE_SPECIFIER",
|
||||
"provisioning profile", "No matching",
|
||||
"requires a provisioning profile"],
|
||||
"provisioning", "描述文件问题:描述文件过期、不匹配或未安装,请检查 Apple Developer 后台"),
|
||||
(["Undefined symbols", "linker command failed", "ld: symbol(s) not found",
|
||||
"ld: framework not found for"],
|
||||
"compilation", "链接错误:符号未定义或框架缺失,请检查代码引用"),
|
||||
(["Swift Compiler Error", "Use of undeclared identifier",
|
||||
"cannot find type", "cannot find symbol in scope", "no such module"],
|
||||
"compilation", "Swift 编译错误:请检查代码语法和类型引用"),
|
||||
([" error:", "fatal error:"],
|
||||
"compilation", "代码编译错误:请检查代码是否有语法或类型错误"),
|
||||
(["CocoaPods", "pod install", "ld: library not found for",
|
||||
"library not found for", "Sandbox is not in sync with Podfile.lock"],
|
||||
"dependency", "依赖问题:Pod 依赖缺失或版本不兼容,请检查 Podfile"),
|
||||
(["xcodebuild: error", "Unable to access scheme", "does not contain a scheme"],
|
||||
"build", "构建配置错误:Scheme 或 Workspace 配置有误"),
|
||||
]
|
||||
|
||||
|
||||
def _classify_build_error(output_lines: list, step: str) -> tuple:
|
||||
"""解析构建输出,返回 (category, friendly_message)
|
||||
|
||||
Args:
|
||||
output_lines: 构建过程的输出行列表
|
||||
step: 失败步骤 (archive/export)
|
||||
|
||||
Returns:
|
||||
(category, friendly_message) 元组
|
||||
"""
|
||||
output_text = "\n".join(output_lines)
|
||||
|
||||
for keywords, category, hint in _ERROR_RULES:
|
||||
for kw in keywords:
|
||||
if kw.lower() in output_text.lower():
|
||||
return category, hint
|
||||
|
||||
# 未匹配到特定分类
|
||||
if step == "archive":
|
||||
return "build", "Archive 失败:请查看详细日志排查原因"
|
||||
elif step == "export":
|
||||
return "build", "导出 IPA 失败:请查看详细日志排查原因"
|
||||
return "unknown", "打包失败:请查看详细日志排查原因"
|
||||
|
||||
|
||||
def _cleanup_build_dir(build_dir: Path):
|
||||
"""删除整个打包目录"""
|
||||
try:
|
||||
@ -234,10 +295,25 @@ async def run_build_task(task_id: str):
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, BuildError):
|
||||
category = e.category
|
||||
error_msg = str(e)
|
||||
else:
|
||||
# 根据异常消息推断分类
|
||||
msg = str(e).lower()
|
||||
if "git" in msg:
|
||||
category = "git"
|
||||
elif "配置替换" in msg:
|
||||
category = "config"
|
||||
elif "混淆" in msg:
|
||||
category = "config"
|
||||
else:
|
||||
category = "unknown"
|
||||
error_msg = str(e)
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="failed", completed_at=datetime.utcnow(),
|
||||
error_message=str(e))
|
||||
await log_streamer.emit_error(task_id, f"打包失败: {str(e)}")
|
||||
error_message=error_msg, error_category=category)
|
||||
await log_streamer.emit_error(task_id, f"打包失败: {error_msg}")
|
||||
if build_dir and build_dir.exists():
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
@ -470,6 +546,7 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
f"-derivedDataPath {export_path / 'derived_data'} "
|
||||
f"-destination generic/platform=ios -quiet"
|
||||
)
|
||||
archive_output = []
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
archive_cmd,
|
||||
cwd=str(build_dir),
|
||||
@ -479,11 +556,13 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
archive_output.append(decoded)
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("Archive 失败")
|
||||
category, hint = _classify_build_error(archive_output, "archive")
|
||||
raise BuildError(hint, category=category, detail="\n".join(archive_output[-20:]))
|
||||
|
||||
# 导出 IPA
|
||||
await log_streamer.emit(task_id, "导出 IPA...")
|
||||
@ -494,6 +573,7 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
f"-exportPath {export_path} "
|
||||
f"-exportOptionsPlist {export_plist}"
|
||||
)
|
||||
export_output = []
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
export_cmd,
|
||||
cwd=str(build_dir),
|
||||
@ -503,11 +583,13 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
export_output.append(decoded)
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("导出 IPA 失败")
|
||||
category, hint = _classify_build_error(export_output, "export")
|
||||
raise BuildError(hint, category=category, detail="\n".join(export_output[-20:]))
|
||||
|
||||
# 查找 IPA 文件
|
||||
ipa_files = list(export_path.glob("*.ipa"))
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<label>选择 Scheme</label>
|
||||
<select v-model="form.scheme_id">
|
||||
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
|
||||
{{ scheme.name }}
|
||||
{{ scheme.displayName || scheme.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -51,14 +51,27 @@
|
||||
<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 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">
|
||||
<div v-for="(log, i) in logs" :key="i" :class="['log-line', log.level]">
|
||||
[{{ formatTime(log.timestamp) }}] {{ log.message }}
|
||||
</div>
|
||||
<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>
|
||||
@ -84,16 +97,23 @@
|
||||
<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 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.oss_url" class="result-row">
|
||||
<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.qr_code_path" class="qr-section">
|
||||
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<p class="qr-hint">扫码下载安装</p>
|
||||
</div>
|
||||
@ -119,6 +139,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
@ -136,7 +157,10 @@ 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 {
|
||||
@ -154,9 +178,19 @@ const connectWs = (taskId) => {
|
||||
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
|
||||
if (!taskId) return
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
@ -205,6 +239,23 @@ onMounted(async () => {
|
||||
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
|
||||
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
|
||||
)
|
||||
@ -279,6 +330,20 @@ 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>
|
||||
@ -335,4 +400,27 @@ const statusText = (s) => {
|
||||
.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>
|
||||
|
||||
@ -88,7 +88,8 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>Scheme 名称</th>
|
||||
<th>显示名称</th>
|
||||
<th>OSS 目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@ -97,6 +98,7 @@
|
||||
<tr v-for="(scheme, id) in schemes" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ scheme.name }}</td>
|
||||
<td>{{ scheme.displayName || '-' }}</td>
|
||||
<td>{{ scheme.ossFloder }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openSchemeModal(id, scheme)">编辑</button>
|
||||
@ -409,6 +411,10 @@
|
||||
<label>Scheme 名称 *</label>
|
||||
<input v-model="schemeForm.name" type="text" placeholder="例如:readoor31">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>显示名称</label>
|
||||
<input v-model="schemeForm.displayName" type="text" placeholder="例如:标准功能">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>OSS 目录 *</label>
|
||||
<input v-model="schemeForm.ossFloder" type="text" placeholder="例如:readoor">
|
||||
@ -636,7 +642,7 @@ const deleteApp = async (id) => {
|
||||
// Scheme 相关
|
||||
const openSchemeModal = (id = null, scheme = null) => {
|
||||
editingSchemeId.value = id
|
||||
schemeForm.value = scheme ? { ...scheme } : { name: '', ossFloder: '' }
|
||||
schemeForm.value = scheme ? { ...scheme } : { name: '', displayName: '', ossFloder: '' }
|
||||
showSchemeModal.value = true
|
||||
}
|
||||
|
||||
|
||||
@ -42,6 +42,9 @@
|
||||
<td>{{ task.scheme_name }}</td>
|
||||
<td>
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
|
||||
{{ errorCategoryLabel(task.error_category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||
@ -58,6 +61,64 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 日志弹窗 -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showLogModal" class="modal-overlay" @click.self="closeLogModal">
|
||||
<div class="log-modal">
|
||||
<div class="log-modal-header">
|
||||
<div class="log-modal-title">
|
||||
<svg class="log-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<div>
|
||||
<h3>{{ logTask?.app_name }}</h3>
|
||||
<span class="log-modal-subtitle">{{ logTask?.scheme_name }} · {{ formatTime(logTask?.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close" @click="closeLogModal">×</button>
|
||||
</div>
|
||||
<div class="log-modal-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">类型</span>
|
||||
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">分支</span>
|
||||
<span class="meta-value">{{ logTask?.branch || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">状态</span>
|
||||
<span :class="['task-status', `status-${logTask?.status}`]">{{ statusText(logTask?.status) }}</span>
|
||||
</div>
|
||||
<div v-if="logTask?.error_category" class="meta-item">
|
||||
<span class="meta-label">原因</span>
|
||||
<span class="error-cat-badge">{{ errorCategoryLabel(logTask?.error_category) }}</span>
|
||||
</div>
|
||||
<div v-if="logTask?.obfuscation" class="meta-item">
|
||||
<span class="meta-label">混淆</span>
|
||||
<span class="meta-value">已启用</span>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-modal-footer">
|
||||
<span class="log-count">{{ logLines.length }} 条记录</span>
|
||||
<button class="btn-close-log" @click="closeLogModal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<div v-if="showQrModal" class="modal-overlay" @click.self="showQrModal = false">
|
||||
<div class="qr-modal">
|
||||
@ -83,20 +144,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, nextTick, onUnmounted } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
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)
|
||||
let logWs = null
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
// 默认隐藏失败和已取消的任务
|
||||
if (!filterStatus.value && (task.status === 'failed' || task.status === 'cancelled')) return false
|
||||
// 默认隐藏已取消的任务
|
||||
if (!filterStatus.value && 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
|
||||
@ -108,10 +172,72 @@ onMounted(async () => {
|
||||
tasks.value = await res.json()
|
||||
})
|
||||
|
||||
const viewLogs = (taskId) => {
|
||||
router.push({ path: '/build', query: { taskId } })
|
||||
const viewLogs = async (taskId) => {
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
logTask.value = task
|
||||
logLines.value = []
|
||||
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: '任务已取消' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectLogWs = (taskId) => {
|
||||
if (logWs) { logWs.close(); logWs = null }
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
logWs = ws
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.level !== 'heartbeat') {
|
||||
logLines.value.push(msg)
|
||||
nextTick(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
ws.onclose = () => { logWs = null }
|
||||
}
|
||||
|
||||
const closeLogModal = () => {
|
||||
if (logWs) { logWs.close(); logWs = null }
|
||||
showLogModal.value = false
|
||||
logTask.value = null
|
||||
logLines.value = []
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (logWs) { logWs.close(); logWs = null }
|
||||
})
|
||||
|
||||
const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
}
|
||||
@ -147,6 +273,20 @@ const statusText = (s) => {
|
||||
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
const errorCategoryLabel = (cat) => {
|
||||
const map = {
|
||||
certificate: '证书问题',
|
||||
provisioning: '描述文件',
|
||||
compilation: '编译错误',
|
||||
dependency: '依赖问题',
|
||||
git: '代码拉取',
|
||||
config: '配置问题',
|
||||
build: '构建配置',
|
||||
unknown: '未知错误',
|
||||
}
|
||||
return map[cat] || '未知错误'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -179,7 +319,86 @@ const statusText = (s) => {
|
||||
.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; }
|
||||
.error-cat-badge {
|
||||
display: inline-block; margin-left: 6px; padding: 2px 8px;
|
||||
border-radius: 4px; font-size: 11px; font-weight: 500;
|
||||
background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
|
||||
/* 弹窗过渡动画 */
|
||||
.modal-enter-active { transition: all 0.25s ease-out; }
|
||||
.modal-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .log-modal, .modal-leave-to .log-modal { transform: translateY(20px) scale(0.97); }
|
||||
|
||||
.log-modal {
|
||||
background: white; border-radius: 16px; width: 740px; max-height: 82vh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.log-modal-header {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.log-modal-title { display: flex; align-items: center; gap: 12px; }
|
||||
.log-icon { width: 36px; height: 36px; color: #1890ff; background: #e6f7ff; border-radius: 10px; padding: 7px; flex-shrink: 0; }
|
||||
.log-modal-title h3 { font-size: 17px; margin: 0; color: #1a1a2e; font-weight: 600; }
|
||||
.log-modal-subtitle { font-size: 12px; color: #999; margin-top: 2px; display: block; }
|
||||
|
||||
/* 元信息栏 */
|
||||
.log-modal-meta {
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
padding: 12px 24px; background: #fafbfc; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.meta-item { display: flex; align-items: center; gap: 6px; }
|
||||
.meta-label { font-size: 12px; color: #999; }
|
||||
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
||||
|
||||
/* 日志内容区 */
|
||||
.log-modal-body {
|
||||
flex: 1; min-height: 0; max-height: 56vh; overflow-y: auto;
|
||||
padding: 16px 20px; background: #0d1117;
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12.5px; line-height: 1.7;
|
||||
}
|
||||
.log-line { margin-bottom: 2px; padding: 1px 0; }
|
||||
.log-line.info { color: #8b949e; }
|
||||
.log-line.info .log-text { color: #8b949e; }
|
||||
.log-line.warn { color: #d29922; }
|
||||
.log-line.error { color: #f85149; }
|
||||
.log-line.step { margin: 8px 0 4px; }
|
||||
.step-indicator {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgba(56, 139, 253, 0.1); border: 1px solid rgba(56, 139, 253, 0.2);
|
||||
padding: 5px 14px; border-radius: 6px;
|
||||
font-weight: 600; color: #58a6ff; font-size: 13px;
|
||||
}
|
||||
.log-empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 48px 0; color: #484f58;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.log-modal-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 24px; border-top: 1px solid #f0f0f0; background: #fafbfc;
|
||||
}
|
||||
.log-count { font-size: 12px; color: #999; }
|
||||
.btn-close-log {
|
||||
padding: 7px 20px; border: 1px solid #d9d9d9; border-radius: 6px;
|
||||
background: white; color: #333; font-size: 13px; cursor: pointer;
|
||||
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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user