2 Commits
Author SHA1 Message Date
shenleiandClaude Opus 4.8 605c09c88a feat: 皮肤包上传区支持拖拽上传
- 抽出 uploadSkinFile 复用点击与拖拽两种上传方式
- 拖入时高亮上传区并提示,松开即触发上传

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:20:04 +09:00
shenleiandClaude Opus 4.8 5014376b81 feat: 支持皮肤包就地更新与新建即传
- 新增皮肤包更新接口,覆盖内容保持文件名不变,避免 APP 配置的主题引用失效
- 上传新皮肤后自动为空主题目录的证书选中该皮肤
- 新建 APP 保存后不关弹窗并回填 upload_key,可直接上传皮肤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:14:08 +09:00
2 changed files with 102 additions and 9 deletions
+28 -1
View File
@@ -316,7 +316,7 @@ async def create_app(app: dict):
apps[new_id] = app apps[new_id] = app
config["apps"] = apps config["apps"] = apps
save_config(config) 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}") @router.put("/apps/{app_id}")
@@ -410,6 +410,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
return {"message": "皮肤包上传成功", "name": file.filename} 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}") @router.delete("/skins/{upload_key}/{skin_name}")
async def delete_skin(upload_key: str, skin_name: str): async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包""" """删除指定皮肤包"""
+74 -8
View File
@@ -519,12 +519,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item"> <div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span> <span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span> <span class="skin-size">{{ formatSize(skin.size) }}</span>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button> <button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div> </div>
</div> </div>
<div v-else class="skins-hint">暂无皮肤包</div> <div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }"> <label class="btn btn-outline skin-upload-btn"
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }} :class="{ disabled: skinUploading, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip或拖拽到此处)') }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden> <input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label> </label>
</div> </div>
@@ -615,6 +624,7 @@ const editingAppId = ref(null)
const appForm = ref({}) const appForm = ref({})
const appSkins = ref([]) const appSkins = ref([])
const skinUploading = ref(false) const skinUploading = ref(false)
const skinDragover = ref(false)
const showSchemeModal = ref(false) const showSchemeModal = ref(false)
const editingSchemeId = ref(null) const editingSchemeId = ref(null)
@@ -862,9 +872,9 @@ const loadSkins = async () => {
} catch { appSkins.value = [] } } catch { appSkins.value = [] }
} }
const uploadSkin = async (event) => { const uploadSkinFile = async (file) => {
const file = event.target.files[0]
if (!file) return if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return } if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true skinUploading.value = true
@@ -880,7 +890,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败') throw new Error(err.detail || '上传失败')
} }
await loadSkins() await loadSkins()
event.target.value = '' // 若某证书的主题目录还是空的,自动选中刚上传的皮肤
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
})
} catch (e) { } catch (e) {
alert('上传失败: ' + e.message) alert('上传失败: ' + e.message)
} finally { } finally {
@@ -888,6 +903,44 @@ const uploadSkin = async (event) => {
} }
} }
const uploadSkin = async (event) => {
await uploadSkinFile(event.target.files[0])
event.target.value = ''
}
const onSkinDrop = (event) => {
skinDragover.value = false
if (skinUploading.value) return
const file = event.dataTransfer.files[0]
uploadSkinFile(file)
}
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) => { const deleteSkin = async (skinName) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
@@ -948,9 +1001,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' })) const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败') throw new Error(err.detail || '保存失败')
} }
showAppModal.value = false
await loadData() 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) { } catch (e) {
alert('保存失败: ' + e.message) alert('保存失败: ' + e.message)
} }
@@ -1160,9 +1222,13 @@ 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-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 { background: #fff1f0; color: #ff4d4f; }
.btn-danger-icon:hover { background: #ff4d4f; color: white; } .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 { 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:hover { background: #e6f7ff; }
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; } .btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
.skin-upload-btn { margin-top: 8px; } .skin-upload-btn { margin-top: 8px; transition: background 0.15s, border-color 0.15s; }
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; } .theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style> </style>