feat: 皮肤包上传管理功能,持久化数据目录

- 新增皮肤包上传/列表/删除 API(基于 upload_key)
- 构建时自动解压 ZIP 皮肤包应用到项目
- 配置和皮肤包迁移到 backend/data/ 目录,切分支不会丢失
- 旧式目录皮肤自动迁移为 ZIP 格式
- 前端皮肤包管理 UI(上传、删除、下拉选择)
- .gitignore 排除运行时数据目录
This commit is contained in:
shen 2026-06-09 10:24:14 +08:00
parent 0ba16635df
commit 9384062b38
5 changed files with 296 additions and 8 deletions

3
.gitignore vendored
View File

@ -10,6 +10,9 @@ __pycache__/
# 数据库
*.db
# 运行时数据(配置、皮肤包)
backend/data/
# 日志
logs/

View File

@ -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"))

View File

@ -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,9 +79,59 @@ 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():
# 迁移:从旧的 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 配置"""

View File

@ -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", "")

View File

@ -484,11 +484,42 @@
</div>
<div class="form-group">
<label>主题目录</label>
<select v-if="appSkins.length"
:value="appSkins.some(s => s.name.replace('.zip', '') === appForm.certificates[certType].theme) ? appForm.certificates[certType].theme : ''"
@change="appForm.certificates[certType].theme = $event.target.value"
class="theme-select">
<option value=""></option>
<option v-for="skin in appSkins" :key="skin.name" :value="skin.name.replace('.zip', '')">
{{ skin.name.replace('.zip', '') }}
</option>
</select>
<div v-if="!appSkins.length || !appSkins.some(s => s.name.replace('.zip', '') === appForm.certificates[certType].theme)" style="margin-top: 4px;">
<input v-model="appForm.certificates[certType].theme" type="text" placeholder="AutoPacking/ymh">
</div>
</div>
</div>
</div>
</div>
<!-- 皮肤包管理 -->
<h4 class="section-title" style="margin-top: 20px;">皮肤包管理</h4>
<div class="skins-section">
<div v-if="!appForm.upload_key" class="skins-hint">请先保存 APP 后再上传皮肤包</div>
<div v-else>
<div class="skins-list" v-if="appSkins.length">
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div>
</div>
<div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }">
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label>
</div>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showAppModal = false">取消</button>
@ -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; }
</style>