feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化

- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败
- 日志保存到独立 backend/logs/ 目录,不随构建目录删除
- 新增任务删除 API 及前端删除按钮
- 构建流程增加 pod install 步骤
- 通过文件传递配置避免命令行参数截断
- Provisioning Profile 支持按名称搜索
- dSYM 下载支持目录打包为 zip
- 前端端口可配置、历史页增加下载列和耗时显示
- 日志弹窗支持展开/收起详细日志
- 自动生成 upload_key 标识
This commit is contained in:
shen 2026-06-08 18:56:43 +08:00
parent 75363ee276
commit f5919a8229
11 changed files with 535 additions and 212 deletions

View File

@ -17,6 +17,7 @@ GIT_REMOTE_URL=git@github.com:your-org/readoor.git
# ---- 服务端口 ----
BACKEND_PORT=8000
FRONTEND_PORT=3000
# ---- 管理员账号 ----
ADMIN_USERNAME=admin
@ -29,6 +30,9 @@ MAX_CONCURRENT_BUILDS=2
# 打包目录保留时间(小时)
BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
# ---- Watchdog 健康监测 ----
# 健康检查间隔(秒)
WATCHDOG_INTERVAL=30

View File

@ -49,6 +49,7 @@ COPY_ITEMS = [
# 服务端口
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "3000"))
# 数据库路径
DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}"
@ -62,6 +63,9 @@ DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2"))
# 默认打包目录保留时间(小时)
DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24"))
# 打包超时时间(小时)
BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1"))
# 管理员账号配置
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")

View File

@ -64,6 +64,17 @@ DEFAULT_SERVERS = {
}
def _ensure_upload_keys(config: dict) -> bool:
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
import uuid
changed = False
for app_id, app in config.get("apps", {}).items():
if not app.get("upload_key"):
app["upload_key"] = uuid.uuid4().hex[:8]
changed = True
return changed
def load_config() -> dict:
"""读取 config.json"""
if not CONFIG_JSON_PATH.exists():
@ -77,6 +88,9 @@ def load_config() -> dict:
config["branches"] = ["main"]
if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD
# 自动为缺少 upload_key 的 app 生成唯一标识
if _ensure_upload_keys(config):
save_config(config)
return config
@ -119,6 +133,7 @@ async def get_apps():
@router.post("/apps")
async def create_app(app: dict):
"""新增 app"""
import uuid
async with _config_lock:
config = load_config()
apps = config.get("apps", {})
@ -127,6 +142,10 @@ async def create_app(app: dict):
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
# 自动生成 upload_key
if not app.get("upload_key"):
app["upload_key"] = uuid.uuid4().hex[:8]
apps[new_id] = app
config["apps"] = apps
save_config(config)

View File

@ -57,6 +57,30 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
return db_task
def _enrich_tasks(tasks):
"""为任务列表补充 has_log 字段"""
from pathlib import Path
logs_dir = Path(__file__).parent.parent / "logs"
result = []
for task in tasks:
resp = TaskResponse.from_orm(task)
# 检查独立日志目录
if logs_dir.exists() and (logs_dir / f"{task.id}.log").exists():
resp.has_log = True
# 检查构建目录
elif task.build_dir:
log_path = Path(task.build_dir) / "build.log"
if log_path.exists():
resp.has_log = True
# 运行中的任务,内存中有日志
elif task.status == "running":
from ..services.log_streamer import log_streamer
if task.id in log_streamer._log_lines:
resp.has_log = True
result.append(resp)
return result
@router.get("", response_model=List[TaskResponse])
async def list_tasks(
status: str = None,
@ -69,7 +93,7 @@ async def list_tasks(
if status:
query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
return tasks
return _enrich_tasks(tasks)
@router.get("/{task_id}", response_model=TaskResponse)
@ -78,7 +102,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return task
return _enrich_tasks([task])[0]
@router.delete("/{task_id}")
@ -98,9 +122,50 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
from ..services.build_queue import build_queue
build_queue.cancel(task_id)
# 清理残留文件
import shutil
from pathlib import Path
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists():
log_path.unlink()
if task.build_dir:
build_path = Path(task.build_dir)
if build_path.exists():
shutil.rmtree(build_path, ignore_errors=True)
return {"message": "任务已取消"}
@router.delete("/{task_id}/delete")
async def delete_task(task_id: str, db: Session = Depends(get_db)):
"""删除打包记录"""
import shutil
from pathlib import Path
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status in ("running", "pending"):
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
# 删除日志文件
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists():
log_path.unlink()
# 删除构建目录
if task.build_dir:
build_path = Path(task.build_dir)
if build_path.exists():
shutil.rmtree(build_path, ignore_errors=True)
db.delete(task)
db.commit()
return {"message": "记录已删除"}
@router.get("/{task_id}/log")
async def get_build_log(task_id: str, db: Session = Depends(get_db)):
"""获取打包日志"""
@ -121,9 +186,14 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
content += f"[{ts}] [{level}] {msg}\n"
return {"log": content}
# 从文件获取(任务已完成)
if task.build_dir:
# 从独立日志目录获取
from pathlib import Path
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists():
return {"log": log_path.read_text(encoding="utf-8")}
# 从构建目录获取(兼容旧数据)
if task.build_dir:
log_path = Path(task.build_dir) / "build.log"
if log_path.exists():
return {"log": log_path.read_text(encoding="utf-8")}
@ -134,16 +204,32 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
@router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
"""下载 dSYM 文件"""
import shutil
import zipfile
import tempfile
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
if not os.path.exists(task.dsym_path):
dsym_path = task.dsym_path
if not os.path.exists(dsym_path):
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path))
# .dSYM 是目录macOS bundle需要压缩为 zip 再下载
if os.path.isdir(dsym_path):
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(dsym_path):
for f in files:
full = os.path.join(root, f)
arcname = os.path.relpath(full, os.path.dirname(dsym_path))
zf.write(full, arcname)
return FileResponse(zip_path, filename=os.path.basename(zip_path), media_type="application/zip")
return FileResponse(dsym_path, filename=os.path.basename(dsym_path))
@router.get("/{task_id}/obfuscation-maps")

View File

@ -36,6 +36,7 @@ class TaskResponse(BaseModel):
error_message: Optional[str]
error_category: Optional[str] = None
config_json: Optional[str]
has_log: bool = False
class Config:
from_attributes = True

View File

@ -14,6 +14,7 @@ from ..config import (
COPY_ITEMS,
DEFAULT_BUILD_DIR_RETENTION_HOURS,
GIT_REMOTE_URL,
BUILD_TIMEOUT_HOURS,
get_source_dir,
)
from .log_streamer import log_streamer
@ -108,6 +109,21 @@ def _cleanup_old_builds(db):
pass
def _cleanup_old_logs():
"""清理超过 7 天的打包日志"""
logs_dir = Path(__file__).parent.parent / "logs"
if not logs_dir.exists():
return
cutoff = datetime.utcnow().timestamp() - 7 * 86400
for f in logs_dir.iterdir():
if f.is_file() and f.suffix == ".log":
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception:
pass
def _db_update(db, task, **fields):
"""同步更新 task 字段并 commit在线程中调用"""
for k, v in fields.items():
@ -209,11 +225,16 @@ async def run_build_task(task_id: str):
await log_streamer.emit_step(task_id, "开始打包")
# 清理过期的打包目录
# 清理过期的打包目录和日志
await asyncio.to_thread(_cleanup_old_builds, db)
await asyncio.to_thread(_cleanup_old_logs)
build_dir = None
try:
timeout_seconds = BUILD_TIMEOUT_HOURS * 3600
async def _do_build():
nonlocal build_dir
# 1. 更新分支源码并拷贝
source_dir = get_source_dir(task.branch)
await asyncio.to_thread(_db_update, db, task, current_step="copy")
@ -240,6 +261,10 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, current_step="patch")
await patch_project(task_id, task, config_data, build_dir)
# 3.5 安装依赖
await asyncio.to_thread(_db_update, db, task, current_step="pod_install")
await run_pod_install(task_id, build_dir)
# 4. 代码混淆(可选)
if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
@ -265,7 +290,7 @@ async def run_build_task(task_id: str):
# 6. 上传分发(仅 Ad_Hoc
if task.build_type == "Ad_Hoc":
await asyncio.to_thread(_db_update, db, task, current_step="upload")
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path)
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path, build_dir)
upload_fields = {}
if oss_url:
upload_fields["oss_url"] = oss_url
@ -284,6 +309,9 @@ async def run_build_task(task_id: str):
if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# 带超时执行打包
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
# 保存打包日志
if build_dir and build_dir.exists():
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
@ -292,6 +320,16 @@ async def run_build_task(task_id: str):
await log_streamer.emit(task_id, "清理临时文件...")
await asyncio.to_thread(_cleanup_build_dir, build_dir)
except asyncio.TimeoutError:
error_msg = f"打包超时(已运行 {BUILD_TIMEOUT_HOURS} 小时)"
await asyncio.to_thread(_db_update, db, task,
status="failed", completed_at=datetime.utcnow(),
error_message=error_msg, error_category="timeout")
await log_streamer.emit_error(task_id, error_msg)
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
except asyncio.CancelledError:
await asyncio.to_thread(_db_update, db, task,
status="cancelled", completed_at=datetime.utcnow())
@ -408,6 +446,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"API": app.get("API", ""),
"APPID_NAME": app.get("name", ""),
"APPID": app.get("AppGuid", app.get("AppId", "")),
"UPLOAD_KEY": app.get("upload_key", task.app_id),
"SCHEME": scheme.get("name", ""),
"OSS_FLODER": scheme.get("ossFloder", ""),
"BUILD_TYPE": task.build_type,
@ -416,8 +455,13 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 证书配置
certificates = app.get("certificates", {})
cert = certificates.get(task.build_type, {})
# 无需证书的应用(如英汉大词典),使用项目默认签名
if not certificates:
config_data["NO_CERT"] = True
await log_streamer.emit(task_id, "该应用无需配置证书,使用项目默认签名")
else:
cert = certificates.get(task.build_type, {})
if cert:
config_data["CERTIFICATE"] = cert.get("cer", "")
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "")
@ -426,14 +470,38 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
if provisioning_profile and os.path.exists(provisioning_profile):
try:
if provisioning_profile:
profile_path = provisioning_profile
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
if not os.path.exists(profile_path):
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
if os.path.isdir(profiles_dir):
import subprocess as _sp
import plistlib as _pl
for filename in os.listdir(profiles_dir):
if not filename.endswith(".mobileprovision"):
continue
candidate = os.path.join(profiles_dir, filename)
try:
plist_xml = _sp.check_output(
["security", "cms", "-D", "-i", provisioning_profile],
["security", "cms", "-D", "-i", candidate],
stderr=_sp.DEVNULL,
)
plist = _pl.loads(plist_xml)
if plist.get("Name") == provisioning_profile:
profile_path = candidate
break
except Exception:
continue
if os.path.exists(profile_path):
try:
import subprocess as _sp
import plistlib as _pl
plist_xml = _sp.check_output(
["security", "cms", "-D", "-i", profile_path],
stderr=_sp.DEVNULL,
)
plist = _pl.loads(plist_xml)
team_ids = plist.get("TeamIdentifier", [])
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
@ -460,20 +528,27 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
"""替换项目配置"""
await log_streamer.emit_step(task_id, "替换项目配置")
# 根据 scheme 选择不同的替换逻辑
if config_data.get("SCHEME") == "readoorDict":
# 清理 RDAppConfiguration.swift 中的注释配置行,避免 replace_line_by_keyword 替换到注释行
swift_config = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
if swift_config.exists():
for keyword in [r"\/\/let RD_APP_GUID:", r"\/\/let RD_API_DOMAIN:"]:
sed_proc = await asyncio.create_subprocess_exec(
"sed", "-i", "", f"/^[[:space:]]*{keyword}/d", str(swift_config),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
await sed_proc.wait()
await log_streamer.emit(task_id, "已清理注释配置行")
# 通过 config_output.json 文件传递配置(避免命令行参数过长截断)
config_file = build_dir / "config_output.json"
mode_flag = "--dict-config-file" if config_data.get("SCHEME") == "readoorDict" else "--config-file"
cmd = [
"python3",
str(AUTOPACKING_DIR / "replace_build_info.py"),
"--dict-config",
json.dumps(config_data, ensure_ascii=False),
]
else:
cmd = [
"python3",
str(AUTOPACKING_DIR / "replace_build_info.py"),
"--config",
json.dumps(config_data, ensure_ascii=False),
mode_flag,
str(config_file),
]
# 使用 subprocess 执行替换
@ -504,6 +579,30 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
await log_streamer.emit(task_id, "项目配置替换完成")
async def run_pod_install(task_id: str, build_dir: Path):
"""安装 CocoaPods 依赖"""
await log_streamer.emit_step(task_id, "安装依赖")
process = await asyncio.create_subprocess_exec(
"pod", "install",
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise BuildError("pod install 失败", category="config")
await log_streamer.emit(task_id, "依赖安装完成")
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path, source_root: Path):
"""执行代码混淆"""
await log_streamer.emit_step(task_id, "代码混淆")
@ -664,7 +763,7 @@ async def find_dsym(task_id: str, build_dir: Path) -> Path:
return None
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple:
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA返回 (download_url, qr_code_path)"""
await log_streamer.emit_step(task_id, "上传分发平台")
@ -676,22 +775,32 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
full_config = load_config()
config_data["_upload_config"] = full_config.get("upload", {})
# 注入路径信息,供 upload_iap.py 使用绝对路径
config_data["IPA_PATH"] = str(ipa_path)
config_data["BUILD_DIR"] = str(build_dir)
config_data["AUTOPACKING_DIR"] = str(AUTOPACKING_DIR)
# 使用现有的 upload_iap 脚本
upload_script = AUTOPACKING_DIR / "upload_iap.py"
if not upload_script.exists():
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
return None, None
# 通过临时文件传递配置
upload_config_file = build_dir / "upload_config.json"
with open(upload_config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2, ensure_ascii=False)
cmd = [
"python3",
str(upload_script),
"--config",
json.dumps(config_data, ensure_ascii=False),
"--config-file",
str(upload_config_file),
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(PROJECT_ROOT),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@ -719,6 +828,6 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
qr_code_path = str(expected_qr)
if process.returncode != 0:
await log_streamer.emit_warning(task_id, "上传可能失败")
raise BuildError("上传分发平台失败", category="upload")
return oss_url, qr_code_path

View File

@ -64,6 +64,12 @@ class LogStreamer:
if not queue:
return
# 回放已缓存的历史日志
existing = self._log_lines.get(task_id, [])
for entry in existing:
yield entry
# 流式推送新消息
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=30)
@ -89,12 +95,15 @@ class LogStreamer:
self._queues.pop(task_id, None)
def save_log(self, task_id: str, build_dir: Path):
"""将收集的日志保存到打包目录"""
lines = self._log_lines.pop(task_id, None)
"""将收集的日志保存到独立日志目录(不会随构建目录删除)"""
lines = self._log_lines.get(task_id)
if not lines:
return
log_path = build_dir / "build.log"
# 保存到 backend/logs/ 目录
logs_dir = Path(__file__).parent.parent / "logs"
logs_dir.mkdir(exist_ok=True)
log_path = logs_dir / f"{task_id}.log"
with open(log_path, "w", encoding="utf-8") as f:
for entry in lines:
ts = entry["timestamp"]
@ -102,8 +111,25 @@ class LogStreamer:
msg = entry["message"]
f.write(f"[{ts}] [{level}] {msg}\n")
def get_log(self, build_dir: Path) -> str:
"""读取已保存的打包日志"""
# 同时保存到构建目录(如果存在)
if build_dir and build_dir.exists():
with open(build_dir / "build.log", "w", encoding="utf-8") as f:
for entry in lines:
ts = entry["timestamp"]
level = entry["level"].upper()
msg = entry["message"]
f.write(f"[{ts}] [{level}] {msg}\n")
def get_log(self, build_dir: Path, task_id: str = None) -> str:
"""读取已保存的打包日志(优先从独立日志目录读取)"""
# 优先从独立日志目录读取
if task_id:
logs_dir = Path(__file__).parent.parent / "logs"
log_path = logs_dir / f"{task_id}.log"
if log_path.exists():
return log_path.read_text(encoding="utf-8")
# 回退到构建目录
if build_dir:
log_path = build_dir / "build.log"
if log_path.exists():
return log_path.read_text(encoding="utf-8")

View File

@ -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()
}

View File

@ -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,8 +111,15 @@
<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-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>
@ -106,6 +127,7 @@
<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">&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>
@ -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,13 +176,32 @@ 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 {
// /
// / 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}` })
}
@ -209,6 +227,7 @@ const viewLogs = async (taskId) => {
}
}
}
}
const connectLogWs = (taskId) => {
if (logWs) { logWs.close(); logWs = null }
@ -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>

View File

@ -1,14 +1,39 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { readFileSync, existsSync } from 'fs'
import { resolve } from 'path'
// 从项目根目录的 .env 读取端口配置
function loadEnv() {
const env = {}
const envPath = resolve(__dirname, '../.env')
if (existsSync(envPath)) {
const content = readFileSync(envPath, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const idx = trimmed.indexOf('=')
const key = trimmed.slice(0, idx).trim()
const value = trimmed.slice(idx + 1).trim()
env[key] = value
}
}
}
return env
}
const env = loadEnv()
const backendPort = env.BACKEND_PORT || '8000'
const frontendPort = env.FRONTEND_PORT || '3000'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
port: parseInt(frontendPort),
proxy: {
'/api': 'http://localhost:8000',
'/api': `http://localhost:${backendPort}`,
'/ws': {
target: 'ws://localhost:8000',
target: `ws://localhost:${backendPort}`,
ws: true,
},
},

View File

@ -11,6 +11,7 @@ if [ -f .env ]; then
fi
PORT="${BACKEND_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
# 确保 .env 存在
if [ ! -f .env ]; then
@ -33,7 +34,7 @@ if [ ! -d "frontend/node_modules" ]; then
fi
echo "=== iOS 自动打包服务(开发模式)==="
echo " 前端: http://localhost:3000"
echo " 前端: http://localhost:$FRONTEND_PORT"
echo " 后端: http://localhost:$PORT"
echo " 按 Ctrl+C 停止所有服务"
echo ""