diff --git a/backend/routers/config.py b/backend/routers/config.py
index 600eef6..24a4c01 100644
--- a/backend/routers/config.py
+++ b/backend/routers/config.py
@@ -316,7 +316,7 @@ async def create_app(app: dict):
apps[new_id] = app
config["apps"] = apps
save_config(config)
- return {"id": new_id, "message": "App 已创建"}
+ return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
@router.put("/apps/{app_id}")
@@ -410,6 +410,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
return {"message": "皮肤包上传成功", "name": file.filename}
+@router.put("/skins/{upload_key}/{skin_name}")
+async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
+ """替换已有皮肤包内容,保持原文件名不变。
+
+ APP 配置的 theme 通过文件名(去掉 .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
+ skin_path = app_skins_dir / skin_name
+ if not skin_path.exists():
+ raise HTTPException(status_code=404, detail="皮肤包不存在")
+
+ content = await file.read()
+ with open(skin_path, "wb") as f:
+ f.write(content)
+
+ return {"message": "皮肤包已更新", "name": skin_name}
+
+
@router.delete("/skins/{upload_key}/{skin_name}")
async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包"""
diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue
index 4158bc3..d2ceabc 100644
--- a/frontend/src/views/ConfigView.vue
+++ b/frontend/src/views/ConfigView.vue
@@ -519,6 +519,10 @@
{{ skin.name }}
{{ formatSize(skin.size) }}
+
@@ -880,6 +884,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败')
}
await loadSkins()
+ // 若某证书的主题目录还是空的,自动选中刚上传的皮肤
+ const skinTheme = file.name.replace(/\.zip$/i, '')
+ const certs = appForm.value.certificates || {}
+ Object.keys(certs).forEach(certType => {
+ if (!certs[certType].theme) certs[certType].theme = skinTheme
+ })
event.target.value = ''
} catch (e) {
alert('上传失败: ' + e.message)
@@ -888,6 +898,32 @@ const uploadSkin = async (event) => {
}
}
+const updateSkin = async (skinName, event) => {
+ const file = event.target.files[0]
+ if (!file) return
+ const uploadKey = appForm.value.upload_key
+ if (!uploadKey) { event.target.value = ''; return }
+ skinUploading.value = true
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, {
+ method: 'PUT',
+ body: formData,
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ throw new Error(err.detail || '更新失败')
+ }
+ await loadSkins()
+ } catch (e) {
+ alert('更新失败: ' + e.message)
+ } finally {
+ event.target.value = ''
+ skinUploading.value = false
+ }
+}
+
const deleteSkin = async (skinName) => {
if (!confirm(`确定删除皮肤包 ${skinName}?`)) return
const uploadKey = appForm.value.upload_key
@@ -948,9 +984,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败')
}
- showAppModal.value = false
await loadData()
- alert('保存成功')
+ if (!editingAppId.value) {
+ // 新建成功:不关弹窗,转为编辑态并回填 upload_key,让皮肤包上传区立即可用
+ const data = await res.json().catch(() => ({}))
+ editingAppId.value = data.id || editingAppId.value
+ if (data.upload_key) appForm.value.upload_key = data.upload_key
+ await loadSkins()
+ alert('保存成功,现在可以上传皮肤包了')
+ } else {
+ showAppModal.value = false
+ alert('保存成功')
+ }
} catch (e) {
alert('保存失败: ' + e.message)
}
@@ -1160,6 +1205,9 @@ const formatJson = () => {
.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; }
+.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
+.skin-update-btn:hover { background: #1890ff; color: white; }
+.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
.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; }