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 BACKEND_PORT=8000
FRONTEND_PORT=3000
# ---- 管理员账号 ---- # ---- 管理员账号 ----
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
@ -29,6 +30,9 @@ MAX_CONCURRENT_BUILDS=2
# 打包目录保留时间(小时) # 打包目录保留时间(小时)
BUILD_DIR_RETENTION_HOURS=24 BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
# ---- Watchdog 健康监测 ---- # ---- Watchdog 健康监测 ----
# 健康检查间隔(秒) # 健康检查间隔(秒)
WATCHDOG_INTERVAL=30 WATCHDOG_INTERVAL=30

View File

@ -49,6 +49,7 @@ COPY_ITEMS = [
# 服务端口 # 服务端口
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) 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'}" 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")) 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_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") 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: def load_config() -> dict:
"""读取 config.json""" """读取 config.json"""
if not CONFIG_JSON_PATH.exists(): if not CONFIG_JSON_PATH.exists():
@ -77,6 +88,9 @@ def load_config() -> dict:
config["branches"] = ["main"] config["branches"] = ["main"]
if "upload" not in config: if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD config["upload"] = DEFAULT_UPLOAD
# 自动为缺少 upload_key 的 app 生成唯一标识
if _ensure_upload_keys(config):
save_config(config)
return config return config
@ -119,6 +133,7 @@ async def get_apps():
@router.post("/apps") @router.post("/apps")
async def create_app(app: dict): async def create_app(app: dict):
"""新增 app""" """新增 app"""
import uuid
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
apps = config.get("apps", {}) 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()] numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" 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 apps[new_id] = app
config["apps"] = apps config["apps"] = apps
save_config(config) save_config(config)

View File

@ -57,6 +57,30 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
return db_task 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]) @router.get("", response_model=List[TaskResponse])
async def list_tasks( async def list_tasks(
status: str = None, status: str = None,
@ -69,7 +93,7 @@ async def list_tasks(
if status: if status:
query = query.filter(Task.status == status) query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() 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) @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() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
return task return _enrich_tasks([task])[0]
@router.delete("/{task_id}") @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 from ..services.build_queue import build_queue
build_queue.cancel(task_id) 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": "任务已取消"} 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") @router.get("/{task_id}/log")
async def get_build_log(task_id: str, db: Session = Depends(get_db)): 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" content += f"[{ts}] [{level}] {msg}\n"
return {"log": content} return {"log": content}
# 从文件获取(任务已完成) # 从独立日志目录获取
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: if task.build_dir:
from pathlib import Path
log_path = Path(task.build_dir) / "build.log" log_path = Path(task.build_dir) / "build.log"
if log_path.exists(): if log_path.exists():
return {"log": log_path.read_text(encoding="utf-8")} 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") @router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, db: Session = Depends(get_db)): async def download_dsym(task_id: str, db: Session = Depends(get_db)):
"""下载 dSYM 文件""" """下载 dSYM 文件"""
import shutil
import zipfile
import tempfile
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path: if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在") 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 文件已被清理") 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") @router.get("/{task_id}/obfuscation-maps")

View File

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

View File

@ -14,6 +14,7 @@ from ..config import (
COPY_ITEMS, COPY_ITEMS,
DEFAULT_BUILD_DIR_RETENTION_HOURS, DEFAULT_BUILD_DIR_RETENTION_HOURS,
GIT_REMOTE_URL, GIT_REMOTE_URL,
BUILD_TIMEOUT_HOURS,
get_source_dir, get_source_dir,
) )
from .log_streamer import log_streamer from .log_streamer import log_streamer
@ -108,6 +109,21 @@ def _cleanup_old_builds(db):
pass 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): def _db_update(db, task, **fields):
"""同步更新 task 字段并 commit在线程中调用""" """同步更新 task 字段并 commit在线程中调用"""
for k, v in fields.items(): for k, v in fields.items():
@ -209,80 +225,92 @@ async def run_build_task(task_id: str):
await log_streamer.emit_step(task_id, "开始打包") await log_streamer.emit_step(task_id, "开始打包")
# 清理过期的打包目录 # 清理过期的打包目录和日志
await asyncio.to_thread(_cleanup_old_builds, db) await asyncio.to_thread(_cleanup_old_builds, db)
await asyncio.to_thread(_cleanup_old_logs)
build_dir = None build_dir = None
try: try:
# 1. 更新分支源码并拷贝 timeout_seconds = BUILD_TIMEOUT_HOURS * 3600
source_dir = get_source_dir(task.branch)
await asyncio.to_thread(_db_update, db, task, current_step="copy")
if GIT_REMOTE_URL:
# 配置了远程仓库,从分支目录获取源码
await update_source(task_id, source_dir, task.branch)
elif source_dir.exists():
# 分支目录已存在,直接使用
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
else:
# 未配置远程仓库且分支目录不存在,使用默认源码目录
source_dir = PROJECT_ROOT
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL使用默认源码: {source_dir}")
build_dir = await copy_source_code(task_id, task, source_dir)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
# 2. 生成配置 async def _do_build():
await asyncio.to_thread(_db_update, db, task, current_step="config") nonlocal build_dir
config_data = await generate_config(task_id, task, build_dir) # 1. 更新分支源码并拷贝
await asyncio.to_thread(_db_update, db, task, source_dir = get_source_dir(task.branch)
config_json=json.dumps(config_data, ensure_ascii=False)) await asyncio.to_thread(_db_update, db, task, current_step="copy")
if GIT_REMOTE_URL:
# 配置了远程仓库,从分支目录获取源码
await update_source(task_id, source_dir, task.branch)
elif source_dir.exists():
# 分支目录已存在,直接使用
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
else:
# 未配置远程仓库且分支目录不存在,使用默认源码目录
source_dir = PROJECT_ROOT
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL使用默认源码: {source_dir}")
build_dir = await copy_source_code(task_id, task, source_dir)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
# 3. 替换项目配置 # 2. 生成配置
await asyncio.to_thread(_db_update, db, task, current_step="patch") await asyncio.to_thread(_db_update, db, task, current_step="config")
await patch_project(task_id, task, config_data, build_dir) config_data = await generate_config(task_id, task, build_dir)
await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False))
# 4. 代码混淆(可选) # 3. 替换项目配置
if task.obfuscation: await asyncio.to_thread(_db_update, db, task, current_step="patch")
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation") await patch_project(task_id, task, config_data, build_dir)
await run_obfuscation(task_id, task, config_data, build_dir, source_dir)
# 5. 构建项目 # 3.5 安装依赖
await asyncio.to_thread(_db_update, db, task, current_step="build") await asyncio.to_thread(_db_update, db, task, current_step="pod_install")
ipa_path = await build_project(task_id, task, config_data, build_dir) await run_pod_install(task_id, build_dir)
# 查找 dSYM # 4. 代码混淆(可选)
dsym_path = await find_dsym(task_id, build_dir) if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
await run_obfuscation(task_id, task, config_data, build_dir, source_dir)
# 查找混淆映射表 # 5. 构建项目
obf_maps_path = build_dir / "obfuscation_maps" await asyncio.to_thread(_db_update, db, task, current_step="build")
ipa_path = await build_project(task_id, task, config_data, build_dir)
update_fields = {"ipa_path": str(ipa_path)} # 查找 dSYM
if dsym_path: dsym_path = await find_dsym(task_id, build_dir)
update_fields["dsym_path"] = str(dsym_path)
if obf_maps_path.exists():
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
await asyncio.to_thread(_db_update, db, task, **update_fields)
# 6. 上传分发(仅 Ad_Hoc # 查找混淆映射表
if task.build_type == "Ad_Hoc": obf_maps_path = build_dir / "obfuscation_maps"
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)
upload_fields = {}
if oss_url:
upload_fields["oss_url"] = oss_url
if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path
if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 完成 update_fields = {"ipa_path": str(ipa_path)}
await asyncio.to_thread(_db_update, db, task, if dsym_path:
status="completed", completed_at=datetime.utcnow(), update_fields["dsym_path"] = str(dsym_path)
current_step=None) if obf_maps_path.exists():
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
await asyncio.to_thread(_db_update, db, task, **update_fields)
await log_streamer.emit_step(task_id, "打包完成") # 6. 上传分发(仅 Ad_Hoc
await log_streamer.emit(task_id, f"IPA: {ipa_path}") if task.build_type == "Ad_Hoc":
if task.oss_url: await asyncio.to_thread(_db_update, db, task, current_step="upload")
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") 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
if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path
if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 完成
await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(),
current_step=None)
await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
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(): if build_dir and build_dir.exists():
@ -292,6 +320,16 @@ async def run_build_task(task_id: str):
await log_streamer.emit(task_id, "清理临时文件...") await log_streamer.emit(task_id, "清理临时文件...")
await asyncio.to_thread(_cleanup_build_dir, build_dir) 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: except asyncio.CancelledError:
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
status="cancelled", completed_at=datetime.utcnow()) 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", ""), "API": app.get("API", ""),
"APPID_NAME": app.get("name", ""), "APPID_NAME": app.get("name", ""),
"APPID": app.get("AppGuid", app.get("AppId", "")), "APPID": app.get("AppGuid", app.get("AppId", "")),
"UPLOAD_KEY": app.get("upload_key", task.app_id),
"SCHEME": scheme.get("name", ""), "SCHEME": scheme.get("name", ""),
"OSS_FLODER": scheme.get("ossFloder", ""), "OSS_FLODER": scheme.get("ossFloder", ""),
"BUILD_TYPE": task.build_type, "BUILD_TYPE": task.build_type,
@ -416,30 +455,59 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 证书配置 # 证书配置
certificates = app.get("certificates", {}) certificates = app.get("certificates", {})
cert = certificates.get(task.build_type, {})
if cert: # 无需证书的应用(如英汉大词典),使用项目默认签名
config_data["CERTIFICATE"] = cert.get("cer", "") if not certificates:
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "") config_data["NO_CERT"] = True
config_data["BUNDLE_ID"] = cert.get("name", "") await log_streamer.emit(task_id, "该应用无需配置证书,使用项目默认签名")
config_data["THEME"] = cert.get("theme", "") else:
cert = certificates.get(task.build_type, {})
if cert:
config_data["CERTIFICATE"] = cert.get("cer", "")
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "")
config_data["BUNDLE_ID"] = cert.get("name", "")
config_data["THEME"] = cert.get("theme", "")
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME # 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "") provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
if provisioning_profile and os.path.exists(provisioning_profile): if provisioning_profile:
try: profile_path = provisioning_profile
import subprocess as _sp # 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
plist_xml = _sp.check_output( if not os.path.exists(profile_path):
["security", "cms", "-D", "-i", provisioning_profile], profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
stderr=_sp.DEVNULL, if os.path.isdir(profiles_dir):
) import subprocess as _sp
import plistlib as _pl import plistlib as _pl
plist = _pl.loads(plist_xml) for filename in os.listdir(profiles_dir):
team_ids = plist.get("TeamIdentifier", []) if not filename.endswith(".mobileprovision"):
config_data["TEAM_ID"] = team_ids[0] if team_ids else "" continue
config_data["PROVISIONING_NAME"] = plist.get("Name", "") candidate = os.path.join(profiles_dir, filename)
except Exception: try:
pass plist_xml = _sp.check_output(
["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 ""
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
except Exception:
pass
# 关联域名等配置 # 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
@ -460,21 +528,28 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
"""替换项目配置""" """替换项目配置"""
await log_streamer.emit_step(task_id, "替换项目配置") await log_streamer.emit_step(task_id, "替换项目配置")
# 根据 scheme 选择不同的替换逻辑 # 清理 RDAppConfiguration.swift 中的注释配置行,避免 replace_line_by_keyword 替换到注释行
if config_data.get("SCHEME") == "readoorDict": swift_config = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
cmd = [ if swift_config.exists():
"python3", for keyword in [r"\/\/let RD_APP_GUID:", r"\/\/let RD_API_DOMAIN:"]:
str(AUTOPACKING_DIR / "replace_build_info.py"), sed_proc = await asyncio.create_subprocess_exec(
"--dict-config", "sed", "-i", "", f"/^[[:space:]]*{keyword}/d", str(swift_config),
json.dumps(config_data, ensure_ascii=False), cwd=str(build_dir),
] stdout=asyncio.subprocess.PIPE,
else: stderr=asyncio.subprocess.STDOUT,
cmd = [ )
"python3", await sed_proc.wait()
str(AUTOPACKING_DIR / "replace_build_info.py"), await log_streamer.emit(task_id, "已清理注释配置行")
"--config",
json.dumps(config_data, ensure_ascii=False), # 通过 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"),
mode_flag,
str(config_file),
]
# 使用 subprocess 执行替换 # 使用 subprocess 执行替换
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@ -504,6 +579,30 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
await log_streamer.emit(task_id, "项目配置替换完成") 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): async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path, source_root: Path):
"""执行代码混淆""" """执行代码混淆"""
await log_streamer.emit_step(task_id, "代码混淆") await log_streamer.emit_step(task_id, "代码混淆")
@ -664,7 +763,7 @@ async def find_dsym(task_id: str, build_dir: Path) -> Path:
return None 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)""" """上传 IPA返回 (download_url, qr_code_path)"""
await log_streamer.emit_step(task_id, "上传分发平台") 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() full_config = load_config()
config_data["_upload_config"] = full_config.get("upload", {}) 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_iap 脚本
upload_script = AUTOPACKING_DIR / "upload_iap.py" upload_script = AUTOPACKING_DIR / "upload_iap.py"
if not upload_script.exists(): if not upload_script.exists():
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传") await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
return None, None 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 = [ cmd = [
"python3", "python3",
str(upload_script), str(upload_script),
"--config", "--config-file",
json.dumps(config_data, ensure_ascii=False), str(upload_config_file),
] ]
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
cwd=str(PROJECT_ROOT), cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, 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) qr_code_path = str(expected_qr)
if process.returncode != 0: if process.returncode != 0:
await log_streamer.emit_warning(task_id, "上传可能失败") raise BuildError("上传分发平台失败", category="upload")
return oss_url, qr_code_path return oss_url, qr_code_path

View File

@ -64,6 +64,12 @@ class LogStreamer:
if not queue: if not queue:
return return
# 回放已缓存的历史日志
existing = self._log_lines.get(task_id, [])
for entry in existing:
yield entry
# 流式推送新消息
while True: while True:
try: try:
msg = await asyncio.wait_for(queue.get(), timeout=30) msg = await asyncio.wait_for(queue.get(), timeout=30)
@ -89,12 +95,15 @@ class LogStreamer:
self._queues.pop(task_id, None) self._queues.pop(task_id, None)
def save_log(self, task_id: str, build_dir: Path): 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: if not lines:
return 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: with open(log_path, "w", encoding="utf-8") as f:
for entry in lines: for entry in lines:
ts = entry["timestamp"] ts = entry["timestamp"]
@ -102,11 +111,28 @@ class LogStreamer:
msg = entry["message"] msg = entry["message"]
f.write(f"[{ts}] [{level}] {msg}\n") f.write(f"[{ts}] [{level}] {msg}\n")
def get_log(self, build_dir: Path) -> str: # 同时保存到构建目录(如果存在)
"""读取已保存的打包日志""" if build_dir and build_dir.exists():
log_path = build_dir / "build.log" with open(build_dir / "build.log", "w", encoding="utf-8") as f:
if log_path.exists(): for entry in lines:
return log_path.read_text(encoding="utf-8") 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")
return "" return ""

View File

@ -352,7 +352,8 @@ const refreshTasks = async () => {
const formatTime = (t) => { const formatTime = (t) => {
if (!t) return '-' 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() return d.toLocaleTimeString()
} }

View File

@ -27,12 +27,18 @@
<th>打包类型</th> <th>打包类型</th>
<th>Scheme</th> <th>Scheme</th>
<th>状态</th> <th>状态</th>
<th>下载地址</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="task in filteredTasks" :key="task.id"> <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>{{ task.app_name }}</td>
<td> <td>
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']"> <span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
@ -46,16 +52,24 @@
{{ errorCategoryLabel(task.error_category) }} {{ errorCategoryLabel(task.error_category) }}
</span> </span>
</td> </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"> <td class="action-btns">
<button class="action-btn" @click="viewLogs(task.id)">日志</button> <button v-if="task.has_log" 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.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.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.status !== 'running' && task.status !== 'pending'" class="action-btn btn-danger" @click="deleteTask(task.id)">删除</button>
<button v-if="task.qr_code_path" class="action-btn" @click="showQrCode(task)">二维码</button>
</td> </td>
</tr> </tr>
<tr v-if="!filteredTasks.length"> <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> </tr>
</tbody> </tbody>
</table> </table>
@ -97,15 +111,23 @@
<span class="meta-value">已启用</span> <span class="meta-value">已启用</span>
</div> </div>
</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 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">
<template v-if="log.level === 'step'"> <div v-if="showVerboseLogs || log.level === 'step' || log.level === 'error' || log.level === 'warn'"
<div class="step-indicator">{{ log.message }}</div> :class="['log-line', log.level]">
</template> <template v-if="log.level === 'step'">
<template v-else> <div class="step-indicator">{{ log.message }}</div>
<span class="log-text">{{ log.message }}</span> </template>
</template> <template v-else>
</div> <span class="log-text">{{ log.message }}</span>
</template>
</div>
</template>
<div v-if="!logLines.length" class="log-empty"> <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> <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> <span>暂无日志</span>
@ -118,28 +140,6 @@
</div> </div>
</div> </div>
</Transition> </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> </div>
</template> </template>
@ -149,12 +149,11 @@ import { ref, computed, onMounted, nextTick, onUnmounted } from 'vue'
const tasks = ref([]) const tasks = ref([])
const filterBuildType = ref('') const filterBuildType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const showQrModal = ref(false)
const qrTask = ref(null)
const showLogModal = ref(false) const showLogModal = ref(false)
const logTask = ref(null) const logTask = ref(null)
const logLines = ref([]) const logLines = ref([])
const logContainer = ref(null) const logContainer = ref(null)
const showVerboseLogs = ref(false)
let logWs = null let logWs = null
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
@ -177,35 +176,55 @@ const viewLogs = async (taskId) => {
if (!task) return if (!task) return
logTask.value = task logTask.value = task
logLines.value = [] logLines.value = []
showVerboseLogs.value = false
showLogModal.value = true showLogModal.value = true
if (task.status === 'running' || task.status === 'pending') { if (task.status === 'running' || task.status === 'pending') {
// WebSocket // WebSocket
connectLogWs(taskId) connectLogWs(taskId)
} else { } else {
// / // / REST API
if (task.error_message) { try {
logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` }) const res = await fetch(`/api/tasks/${taskId}/log`)
} if (res.ok) {
if (task.error_category) { const data = await res.json()
logLines.value.push({ level: 'warn', message: `分类: ${errorCategoryLabel(task.error_category)}` }) if (data.log) {
} const lines = data.log.split('\n').filter(Boolean)
if (task.config_json) { logLines.value = lines.map(line => {
try { const m = line.match(/^\[(.+?)\]\s+\[(.+?)\]\s+(.*)$/)
const cfg = JSON.parse(task.config_json) if (m) {
logLines.value.push({ level: 'info', message: `版本: ${cfg.VERSION} Build: ${cfg.BUILD_VERSION}` }) return { timestamp: m[1], level: m[2].toLowerCase(), message: m[3] }
logLines.value.push({ level: 'info', message: `Scheme: ${cfg.SCHEME} 类型: ${cfg.BUILD_TYPE}` }) }
if (cfg.CERTIFICATE) logLines.value.push({ level: 'info', message: `证书: ${cfg.CERTIFICATE}` }) return { timestamp: '', level: 'info', message: line }
if (cfg.BUNDLE_ID) logLines.value.push({ level: 'info', message: `BundleID: ${cfg.BUNDLE_ID}` }) })
} catch {} }
} }
if (task.status === 'completed') { } catch {}
logLines.value.push({ level: 'step', message: '[打包完成]' }) // 退
if (task.oss_url) logLines.value.push({ level: 'info', message: `下载链接: ${task.oss_url}` }) if (!logLines.value.length) {
} else if (task.status === 'failed') { if (task.error_message) {
logLines.value.push({ level: 'step', message: '[打包失败]' }) logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` })
} else if (task.status === 'cancelled') { }
logLines.value.push({ level: 'info', 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`) window.open(`/api/tasks/${taskId}/obfuscation-maps`)
} }
const downloadIpa = (taskId) => { const deleteTask = async (taskId) => {
window.open(`/api/tasks/${taskId}/ipa`) if (!confirm('确定要删除这条打包记录吗?')) return
} try {
const res = await fetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
const showQrCode = (task) => { if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
qrTask.value = task } catch {}
showQrModal.value = true
}
const downloadQrCode = () => {
if (qrTask.value) {
const link = document.createElement('a')
link.href = `/api/tasks/${qrTask.value.id}/qrcode`
link.download = `${qrTask.value.app_name}_二维码.png`
link.click()
}
} }
const formatTime = (t) => { const formatTime = (t) => {
if (!t) return '-' 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) => { const statusText = (s) => {
@ -283,6 +308,8 @@ const errorCategoryLabel = (cat) => {
git: '代码拉取', git: '代码拉取',
config: '配置问题', config: '配置问题',
build: '构建配置', build: '构建配置',
timeout: '打包超时',
upload: '上传失败',
unknown: '未知错误', unknown: '未知错误',
} }
return map[cat] || '未知错误' 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 { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; min-width: 120px; }
.filter-select:focus { outline: none; border-color: #1890ff; } .filter-select:focus { outline: none; border-color: #1890ff; }
.config-table { width: 100%; border-collapse: collapse; } .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; } .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 th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; } .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; } .config-table tr:hover { background: #fafafa; }
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } .build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.badge-adhoc { background: #e6f7ff; color: #1890ff; } .badge-adhoc { background: #e6f7ff; color: #1890ff; }
.badge-appstore { background: #f6ffed; color: #52c41a; } .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 { 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; } .action-btn:hover { border-color: #1890ff; color: #1890ff; }
@ -363,6 +399,22 @@ const errorCategoryLabel = (cat) => {
.meta-label { font-size: 12px; color: #999; } .meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 13px; color: #333; font-weight: 500; } .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 { .log-modal-body {
flex: 1; min-height: 0; max-height: 56vh; overflow-y: auto; flex: 1; min-height: 0; max-height: 56vh; overflow-y: auto;
@ -399,16 +451,11 @@ const errorCategoryLabel = (cat) => {
transition: all 0.2s; transition: all 0.2s;
} }
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.qr-modal { background: white; border-radius: 12px; padding: 24px; width: 400px; } .download-link { color: #1890ff; text-decoration: none; font-size: 13px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .download-link:hover { text-decoration: underline; }
.modal-header h3 { font-size: 16px; margin: 0; } .text-muted { color: #999; font-size: 13px; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.qr-content { text-align: center; } .qr-thumb:hover { border-color: #1890ff; }
.qr-image { width: 200px; height: 200px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 16px; } .btn-danger { color: #ff4d4f; border-color: #ff4d4f; }
.qr-info { text-align: left; padding: 16px; background: #fafafa; border-radius: 8px; margin-bottom: 16px; } .btn-danger:hover { background: #ff4d4f; color: white; }
.qr-info p { margin: 8px 0; font-size: 14px; color: #333; }
.qr-actions { display: flex; justify-content: center; }
.btn { padding: 10px 24px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.btn-primary { background: #1890ff; color: white; }
.btn-primary:hover { background: #40a9ff; }
</style> </style>

View File

@ -1,14 +1,39 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' 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({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 3000, port: parseInt(frontendPort),
proxy: { proxy: {
'/api': 'http://localhost:8000', '/api': `http://localhost:${backendPort}`,
'/ws': { '/ws': {
target: 'ws://localhost:8000', target: `ws://localhost:${backendPort}`,
ws: true, ws: true,
}, },
}, },

View File

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