diff --git a/.gitignore b/.gitignore index cc2e9e9..a952231 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ __pycache__/ # 数据库 *.db +# 运行时数据(配置、皮肤包) +backend/data/ + # 日志 logs/ diff --git a/backend/config.py b/backend/config.py index da3b6c9..b96823a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -15,11 +15,18 @@ if _env_file.exists(): # 项目根目录(iOS 源码,也作为 AutoPacking 脚本来源) PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "/Users/shen/Work/Code/Readoor")) -# AutoPacking 目录 +# AutoPacking 目录(打包脚本来源,随分支变化) AUTOPACKING_DIR = PROJECT_ROOT / "AutoPacking" -# config.json 路径 -CONFIG_JSON_PATH = AUTOPACKING_DIR / "config.json" +# 持久化数据目录(不随分支变化,存放运行时配置和皮肤包) +DATA_DIR = Path(os.getenv("DATA_DIR", str(Path(__file__).parent / "data"))) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +# config.json 路径(持久化,不随分支变化) +CONFIG_JSON_PATH = DATA_DIR / "config.json" + +# 皮肤包存储目录(持久化,不随分支变化) +SKINS_DIR = DATA_DIR / "skins" # 打包基础目录 BUILD_BASE_DIR = Path(os.getenv("BUILD_BASE_DIR", "/Users/shen/Documents")) diff --git a/backend/routers/config.py b/backend/routers/config.py index fc6aca9..9c7fe6d 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -1,15 +1,18 @@ """配置管理 API""" import asyncio import json +import os import shutil +import zipfile from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.orm import Session from ..database import get_db from ..models import BuildConfig from ..schemas import BuildConfigUpdate -from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS +from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS, SKINS_DIR, AUTOPACKING_DIR from ..deps import get_current_user router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)]) @@ -76,10 +79,60 @@ def _ensure_upload_keys(config: dict) -> bool: return changed +def _migrate_old_themes(config: dict) -> bool: + """将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更""" + changed = False + for app_id, app in config.get("apps", {}).items(): + upload_key = app.get("upload_key", "") + if not upload_key: + continue + for cert_type, cert in app.get("certificates", {}).items(): + theme = cert.get("theme", "") + if not theme: + continue + # 已经是 ZIP 皮肤(upload_key 目录下有对应 ZIP),跳过 + if (SKINS_DIR / upload_key / f"{theme}.zip").exists(): + continue + # 在 AutoPacking 目录下查找皮肤目录 + skin_name = Path(theme).name # "AutoPacking/ymh" → "ymh", "ymh" → "ymh" + theme_dir = AUTOPACKING_DIR / skin_name + if not theme_dir.is_dir(): + continue + app_skins_dir = SKINS_DIR / upload_key + app_skins_dir.mkdir(parents=True, exist_ok=True) + zip_path = app_skins_dir / f"{skin_name}.zip" + # 将目录打包为 ZIP + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _, files in os.walk(theme_dir): + for f in files: + file_path = Path(root) / f + arcname = file_path.relative_to(theme_dir.parent) + zf.write(file_path, arcname) + # 更新 skins 列表和 theme 引用 + skins = app.get("skins", []) + if f"{skin_name}.zip" not in skins: + skins.append(f"{skin_name}.zip") + app["skins"] = skins + if theme != skin_name: + cert["theme"] = skin_name + changed = True + return changed + + def load_config() -> dict: """读取 config.json""" if not CONFIG_JSON_PATH.exists(): - return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD} + # 迁移:从旧的 AutoPacking/config.json 迁移到持久化目录 + old_config = AUTOPACKING_DIR / "config.json" + if old_config.exists(): + CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(old_config, CONFIG_JSON_PATH) + # 迁移旧的 skins 目录 + old_skins = AUTOPACKING_DIR / "skins" + if old_skins.exists(): + shutil.copytree(old_skins, SKINS_DIR, dirs_exist_ok=True) + else: + return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD} with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f: config = json.load(f) # 确保必要字段存在 @@ -92,6 +145,9 @@ def load_config() -> dict: # 自动为缺少 upload_key 的 app 生成唯一标识 if _ensure_upload_keys(config): save_config(config) + # 迁移旧式目录皮肤为 ZIP + if _migrate_old_themes(config): + save_config(config) return config @@ -185,6 +241,92 @@ async def delete_app(app_id: str): return {"message": "App 已删除"} +def _find_app_by_upload_key(config: dict, upload_key: str): + """根据 upload_key 查找 app,返回 (app_id, app) 或 (None, None)""" + for app_id, app in config.get("apps", {}).items(): + if app.get("upload_key") == upload_key: + return app_id, app + return None, None + + +@router.get("/skins/{upload_key}") +async def list_skins(upload_key: str): + """列出指定 app 的皮肤包""" + config = load_config() + _, app = _find_app_by_upload_key(config, upload_key) + if not app: + raise HTTPException(status_code=404, detail="App 不存在") + + app_skins_dir = SKINS_DIR / upload_key + skins = [] + if app_skins_dir.exists(): + for f in sorted(app_skins_dir.iterdir()): + if f.suffix.lower() == ".zip": + skins.append({ + "name": f.name, + "size": f.stat().st_size, + }) + return skins + + +@router.post("/skins/{upload_key}") +async def upload_skin(upload_key: str, file: UploadFile = File(...)): + """上传皮肤包(ZIP 文件)""" + if not file.filename or not file.filename.lower().endswith(".zip"): + raise HTTPException(status_code=400, detail="仅支持 .zip 格式") + + config = load_config() + app_id, _ = _find_app_by_upload_key(config, upload_key) + if not app_id: + raise HTTPException(status_code=404, detail="App 不存在") + + app_skins_dir = SKINS_DIR / upload_key + app_skins_dir.mkdir(parents=True, exist_ok=True) + + dest = app_skins_dir / file.filename + with open(dest, "wb") as f: + content = await file.read() + f.write(content) + + # 更新 config.json 中的 skins 列表 + async with _config_lock: + config = load_config() + skins = config["apps"][app_id].get("skins", []) + if file.filename not in skins: + skins.append(file.filename) + config["apps"][app_id]["skins"] = skins + save_config(config) + + return {"message": "皮肤包上传成功", "name": file.filename} + + +@router.delete("/skins/{upload_key}/{skin_name}") +async def delete_skin(upload_key: str, skin_name: str): + """删除指定皮肤包""" + config = load_config() + app_id, _ = _find_app_by_upload_key(config, upload_key) + if not app_id: + raise HTTPException(status_code=404, detail="App 不存在") + + app_skins_dir = SKINS_DIR / upload_key + skin_path = app_skins_dir / skin_name + if not skin_path.exists(): + raise HTTPException(status_code=404, detail="皮肤包不存在") + + skin_path.unlink() + + # 更新 config.json 中的 skins 列表 + async with _config_lock: + config = load_config() + skins = config["apps"][app_id].get("skins", []) + if skin_name in skins: + skins.remove(skin_name) + config["apps"][app_id]["skins"] = skins + save_config(config) + + return {"message": "皮肤包已删除"} + + @router.get("/schemes") async def get_schemes(): """获取 schemes 配置""" diff --git a/backend/services/build_service.py b/backend/services/build_service.py index e7772db..2ba0979 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -3,6 +3,7 @@ import asyncio import json import os import shutil +import zipfile from datetime import datetime from pathlib import Path from typing import List @@ -15,6 +16,7 @@ from ..config import ( DEFAULT_BUILD_DIR_RETENTION_HOURS, GIT_REMOTE_URL, BUILD_TIMEOUT_HOURS, + SKINS_DIR, get_source_dir, ) from .log_streamer import log_streamer @@ -466,7 +468,38 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict: 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", "") + theme_value = cert.get("theme", "") + config_data["THEME"] = theme_value + + # 解析皮肤:优先识别旧式目录(AutoPacking/ymh),再尝试上传的 ZIP + if theme_value: + theme_resolved = False + # 1) 相对路径:在 build_dir(源码副本)下查找 + candidate = build_dir / theme_value + if candidate.is_dir(): + config_data["THEME"] = str(candidate) + theme_resolved = True + # 2) 绝对路径:直接使用 + if not theme_resolved and os.path.isabs(theme_value) and os.path.isdir(theme_value): + theme_resolved = True + # 3) 上传的 ZIP 皮肤包 + if not theme_resolved: + # 皮肤名可能是 "ymh" 或 "AutoPacking/ymh",取最后一段作为 ZIP 名 + skin_name = Path(theme_value).name + skin_zip = SKINS_DIR / app.get("upload_key", "") / f"{skin_name}.zip" + if skin_zip.exists(): + skin_extract_dir = build_dir / "skin_temp" + skin_extract_dir.mkdir(exist_ok=True) + with zipfile.ZipFile(skin_zip, "r") as zf: + zf.extractall(skin_extract_dir) + entries = list(skin_extract_dir.iterdir()) + if len(entries) == 1 and entries[0].is_dir(): + config_data["THEME"] = str(entries[0]) + else: + config_data["THEME"] = str(skin_extract_dir) + await log_streamer.emit(task_id, f"皮肤包 {skin_name}.zip 已解压") + else: + await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在") # 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME provisioning_profile = config_data.get("PROVISIONING_PROFILE", "") diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index 1030fda..da320f7 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -484,12 +484,43 @@
- + +
+ +
+ +

皮肤包管理

+
+
请先保存 APP 后再上传皮肤包
+
+
+
+ {{ skin.name }} + {{ formatSize(skin.size) }} + +
+
+
暂无皮肤包
+ +
+
+
@@ -567,6 +598,8 @@ const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' }) const showAppModal = ref(false) const editingAppId = ref(null) const appForm = ref({}) +const appSkins = ref([]) +const skinUploading = ref(false) const showSchemeModal = ref(false) const editingSchemeId = ref(null) @@ -760,6 +793,7 @@ const openAppModal = (id = null, app = null) => { } } showAppModal.value = true + loadSkins() } const onServerChange = () => { @@ -786,6 +820,60 @@ const toggleCert = (certType) => { } } +// 皮肤包管理 +const loadSkins = async () => { + const uploadKey = appForm.value.upload_key + if (!uploadKey) { appSkins.value = []; return } + try { + const res = await authFetch(`/api/config/skins/${uploadKey}`) + if (res.ok) appSkins.value = await res.json() + } catch { appSkins.value = [] } +} + +const uploadSkin = async (event) => { + const file = event.target.files[0] + if (!file) return + const uploadKey = appForm.value.upload_key + if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return } + skinUploading.value = true + try { + const formData = new FormData() + formData.append('file', file) + const res = await authFetch(`/api/config/skins/${uploadKey}`, { + method: 'POST', + body: formData, + }) + if (!res.ok) { + const err = await res.json() + throw new Error(err.detail || '上传失败') + } + await loadSkins() + event.target.value = '' + } catch (e) { + alert('上传失败: ' + e.message) + } finally { + skinUploading.value = false + } +} + +const deleteSkin = async (skinName) => { + if (!confirm(`确定删除皮肤包 ${skinName}?`)) return + const uploadKey = appForm.value.upload_key + try { + const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('删除失败') + await loadSkins() + } catch (e) { + alert('删除失败: ' + e.message) + } +} + +const formatSize = (bytes) => { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + const saveApp = async () => { if (!appForm.value.name || !appForm.value.AppGuid) { alert('请填写必填字段:App 名称、AppGuid') @@ -1030,4 +1118,19 @@ const formatJson = () => { .checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 500; } .checkbox-label input { width: 16px; height: 16px; } .cert-fields { padding-top: 8px; } +.skins-section { padding: 12px; background: #fafafa; border-radius: 8px; } +.skins-hint { color: #999; font-size: 13px; margin-bottom: 8px; } +.skins-list { margin-bottom: 12px; } +.skin-item { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; } +.skin-item:last-child { border-bottom: none; } +.skin-name { font-size: 14px; color: #333; flex: 1; } +.skin-size { font-size: 12px; color: #999; } +.btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; } +.btn-danger-icon { background: #fff1f0; color: #ff4d4f; } +.btn-danger-icon:hover { background: #ff4d4f; color: white; } +.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; } +.btn-outline:hover { background: #e6f7ff; } +.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; } +.skin-upload-btn { margin-top: 8px; } +.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }