feat: 皮肤包上传管理功能,持久化数据目录
- 新增皮肤包上传/列表/删除 API(基于 upload_key) - 构建时自动解压 ZIP 皮肤包应用到项目 - 配置和皮肤包迁移到 backend/data/ 目录,切分支不会丢失 - 旧式目录皮肤自动迁移为 ZIP 格式 - 前端皮肤包管理 UI(上传、删除、下拉选择) - .gitignore 排除运行时数据目录
This commit is contained in:
parent
0ba16635df
commit
9384062b38
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,6 +10,9 @@ __pycache__/
|
||||
# 数据库
|
||||
*.db
|
||||
|
||||
# 运行时数据(配置、皮肤包)
|
||||
backend/data/
|
||||
|
||||
# 日志
|
||||
logs/
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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 配置"""
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -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="删除">×</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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user