feat: add PC admin App Version Management page with APK upload
- Backend: CRUD endpoints for app versions (list, create, delete, download) - Backend: APK file upload with multipart form data - Backend: Version data stored in JSON file, APK files in data/app_versions/ - Frontend: Vue 3 + Element Plus page at /app-versions - Frontend: Drag-and-drop APK upload, version publish, force update config - Frontend: Version history table with "set as latest" and delete actions - Menu item under System Settings (admin only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,80 @@
|
||||
"""
|
||||
应用升级检查 API
|
||||
应用升级检查 & 版本管理 API
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/app",
|
||||
tags=["app"],
|
||||
)
|
||||
|
||||
# 升级配置(可从 .env 或数据库读取,默认从配置文件)
|
||||
_LATEST_ANDROID_VERSION = getattr(settings, 'LATEST_ANDROID_VERSION', settings.APP_VERSION)
|
||||
_LATEST_ANDROID_URL = getattr(settings, 'LATEST_ANDROID_URL', "")
|
||||
_ANDROID_FORCE_UPDATE_MIN = getattr(settings, 'ANDROID_FORCE_UPDATE_MIN', "1.0.0")
|
||||
_ANDROID_RELEASE_NOTES = getattr(settings, 'ANDROID_RELEASE_NOTES', "")
|
||||
# 版本数据存储路径
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_DATA_DIR = _BACKEND_DIR / "data" / "app_versions"
|
||||
_VERSIONS_FILE = _DATA_DIR / "versions.json"
|
||||
_APK_DIR = _DATA_DIR / "apks"
|
||||
|
||||
|
||||
def _ensure_dirs():
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_APK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _load_versions() -> dict:
|
||||
"""加载版本数据。"""
|
||||
_ensure_dirs()
|
||||
if not _VERSIONS_FILE.exists():
|
||||
return {"versions": [], "latest": {}}
|
||||
try:
|
||||
with open(_VERSIONS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"versions": [], "latest": {}}
|
||||
|
||||
|
||||
def _save_versions(data: dict):
|
||||
"""保存版本数据。"""
|
||||
_ensure_dirs()
|
||||
with open(_VERSIONS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def _get_latest_version_info() -> dict:
|
||||
"""获取最新版本信息(供 check-update 使用)。"""
|
||||
data = _load_versions()
|
||||
latest = data.get("latest", {})
|
||||
if not latest:
|
||||
return {
|
||||
"version": "0.0.0",
|
||||
"url": "",
|
||||
"force_update_min": "1.0.0",
|
||||
"release_notes": "",
|
||||
}
|
||||
return latest
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple:
|
||||
"""将版本号字符串转为可比较的元组。"""
|
||||
try:
|
||||
return tuple(int(x) for x in v.strip().split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return (0,)
|
||||
|
||||
|
||||
# ─── 客户端检查更新 ──────────────────────────────────────────
|
||||
|
||||
@router.get("/check-update")
|
||||
async def check_update(
|
||||
platform: str = Query("android", description="平台: android / ios"),
|
||||
@@ -32,28 +82,139 @@ async def check_update(
|
||||
):
|
||||
"""检查应用是否有新版本可用。"""
|
||||
if platform == "android":
|
||||
latest = _LATEST_ANDROID_VERSION
|
||||
url = _LATEST_ANDROID_URL
|
||||
force_min = _ANDROID_FORCE_UPDATE_MIN
|
||||
notes = _ANDROID_RELEASE_NOTES
|
||||
info = _get_latest_version_info()
|
||||
latest_ver = info.get("version", "0.0.0")
|
||||
url = info.get("url", "")
|
||||
force_min = info.get("force_update_min", "1.0.0")
|
||||
notes = info.get("release_notes", "")
|
||||
else:
|
||||
latest = settings.APP_VERSION
|
||||
latest_ver = "0.0.0"
|
||||
url = ""
|
||||
force_min = "1.0.0"
|
||||
notes = ""
|
||||
|
||||
current_tuple = _version_tuple(version)
|
||||
latest_tuple = _version_tuple(latest)
|
||||
latest_tuple = _version_tuple(latest_ver)
|
||||
force_tuple = _version_tuple(force_min)
|
||||
|
||||
has_update = current_tuple < latest_tuple
|
||||
has_update = latest_tuple > (0,) and current_tuple < latest_tuple
|
||||
force_update = current_tuple < force_tuple
|
||||
|
||||
return {
|
||||
"latest_version": latest,
|
||||
"latest_version": latest_ver,
|
||||
"current_version": version,
|
||||
"has_update": has_update,
|
||||
"force_update": force_update,
|
||||
"update_url": url or "",
|
||||
"release_notes": notes,
|
||||
}
|
||||
|
||||
|
||||
# ─── 管理端版本列表 ──────────────────────────────────────────
|
||||
|
||||
async def _get_admin_user(token: str, db: Session) -> User:
|
||||
"""验证管理员身份。"""
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
user = db.query(User).filter(User.id == payload.get("sub")).first()
|
||||
if not user or user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/versions")
|
||||
async def list_versions():
|
||||
"""管理端:获取所有版本历史。"""
|
||||
data = _load_versions()
|
||||
return {
|
||||
"versions": data.get("versions", []),
|
||||
"latest": data.get("latest", {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/versions")
|
||||
async def create_version(
|
||||
file: UploadFile = File(None),
|
||||
version: str = Form(...),
|
||||
platform: str = Form("android"),
|
||||
force_update_min: str = Form(""),
|
||||
release_notes: str = Form(""),
|
||||
):
|
||||
"""管理端:上传 APK 并创建新版本。"""
|
||||
_ensure_dirs()
|
||||
|
||||
data = _load_versions()
|
||||
|
||||
# 保存 APK 文件
|
||||
apk_url = ""
|
||||
if file and file.filename:
|
||||
if not file.filename.endswith(".apk"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 .apk 文件")
|
||||
safe_name = f"{version}.apk"
|
||||
file_path = _APK_DIR / safe_name
|
||||
try:
|
||||
with open(file_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
apk_url = f"/api/v1/app/download/{safe_name}"
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"文件保存失败: {e}")
|
||||
|
||||
# 检查版本是否已存在
|
||||
versions = data.get("versions", [])
|
||||
existing = next((v for v in versions if v["version"] == version and v["platform"] == platform), None)
|
||||
|
||||
new_entry = {
|
||||
"version": version,
|
||||
"platform": platform,
|
||||
"url": apk_url or (existing.get("url", "") if existing else ""),
|
||||
"force_update_min": force_update_min,
|
||||
"release_notes": release_notes,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
# 更新已有版本
|
||||
existing.update(new_entry)
|
||||
else:
|
||||
versions.append(new_entry)
|
||||
|
||||
# 发布为最新版本
|
||||
data["latest"] = new_entry
|
||||
data["versions"] = sorted(versions, key=lambda v: _version_tuple(v["version"]), reverse=True)
|
||||
|
||||
_save_versions(data)
|
||||
|
||||
return {"message": f"版本 {version} 发布成功", "version": new_entry}
|
||||
|
||||
|
||||
@router.delete("/versions/{version_str}")
|
||||
async def delete_version(version_str: str):
|
||||
"""管理端:删除版本记录(不删除 APK 文件)。"""
|
||||
data = _load_versions()
|
||||
versions = data.get("versions", [])
|
||||
filtered = [v for v in versions if v["version"] != version_str]
|
||||
|
||||
if len(filtered) == len(versions):
|
||||
raise HTTPException(status_code=404, detail="版本不存在")
|
||||
|
||||
data["versions"] = filtered
|
||||
|
||||
# 如果删除的是最新版本,重新选择
|
||||
latest = data.get("latest", {})
|
||||
if latest.get("version") == version_str:
|
||||
data["latest"] = filtered[0] if filtered else {}
|
||||
|
||||
_save_versions(data)
|
||||
return {"message": f"版本 {version_str} 已删除"}
|
||||
|
||||
|
||||
@router.get("/download/{filename}")
|
||||
async def download_apk(filename: str):
|
||||
"""下载 APK 文件。"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
file_path = _APK_DIR / filename
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
return FileResponse(file_path, media_type="application/vnd.android.package-archive", filename=filename)
|
||||
|
||||
@@ -104,7 +104,7 @@ import {
|
||||
Grid, Document, User, ChatLineSquare, Share, UserFilled,
|
||||
Clock, Monitor, List, Connection, Setting, Star, Shop,
|
||||
Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis,
|
||||
Sunny, Moon
|
||||
Sunny, Moon, Iphone
|
||||
} from '@element-plus/icons-vue'
|
||||
import GlobalSearch from '@/components/GlobalSearch.vue'
|
||||
import BreadcrumbNav from '@/components/BreadcrumbNav.vue'
|
||||
@@ -189,6 +189,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
{ index: 'model-configs', label: '模型配置管理', description: '管理LLM模型', path: '/model-configs', icon: Setting },
|
||||
{ index: 'workspaces', label: '工作区', description: '管理多工作区', path: '/workspaces', pathPattern: '/workspace', icon: OfficeBuilding },
|
||||
{ index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true },
|
||||
{ index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -207,6 +207,12 @@ const router = createRouter({
|
||||
name: 'teams',
|
||||
component: () => import('@/views/TeamBuilder.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/app-versions',
|
||||
name: 'app-versions',
|
||||
component: () => import('@/views/AppVersionManage.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
248
frontend/src/views/AppVersionManage.vue
Normal file
248
frontend/src/views/AppVersionManage.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="app-version-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>App 版本管理</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 当前最新版本 -->
|
||||
<el-alert
|
||||
v-if="latest.version"
|
||||
:title="`当前已发布: v${latest.version} (${latest.platform || 'android'})`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
title="尚未发布任何版本"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
|
||||
<!-- 发布新版本 -->
|
||||
<el-divider content-position="left">发布新版本</el-divider>
|
||||
<el-form :model="form" label-width="120px" style="max-width: 600px">
|
||||
<el-form-item label="APK 文件">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
accept=".apk"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽 APK 文件到此处 或 <em>点击上传</em></div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="版本号" required>
|
||||
<el-input v-model="form.version" placeholder="如 1.0.1" style="width: 200px" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="强制更新最低版本">
|
||||
<el-input v-model="form.forceUpdateMin" placeholder="低于此版本将强制更新,如 1.0.0" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">
|
||||
当前版本低于此值时,客户端将强制要求更新(不可跳过)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="更新日志">
|
||||
<el-input
|
||||
v-model="form.releaseNotes"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述本次更新内容,支持换行"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handlePublish" :loading="publishing">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ form.version && latest.version && form.version !== latest.version ? '发布新版本' : '更新发布' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 版本历史 -->
|
||||
<el-divider content-position="left">版本历史</el-divider>
|
||||
<el-table :data="versions" stripe>
|
||||
<el-table-column prop="version" label="版本号" width="120" />
|
||||
<el-table-column prop="platform" label="平台" width="80" />
|
||||
<el-table-column prop="release_notes" label="更新日志" min-width="200" />
|
||||
<el-table-column prop="force_update_min" label="强制更新阈值" width="140" />
|
||||
<el-table-column prop="created_at" label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleSetLatest(row)"
|
||||
>
|
||||
设为最新
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除此版本?"
|
||||
@confirm="handleDelete(row.version)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger" link>删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload, UploadFilled } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import api from '@/api'
|
||||
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const publishing = ref(false)
|
||||
const latest = ref<Record<string, any>>({})
|
||||
const versions = ref<Array<Record<string, any>>>([])
|
||||
const selectedFile = ref<File | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
version: '',
|
||||
forceUpdateMin: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await api.get('/api/v1/app/versions')
|
||||
latest.value = res.data.latest || {}
|
||||
versions.value = res.data.versions || []
|
||||
// 预填表单
|
||||
if (latest.value.version && !form.version) {
|
||||
form.version = bumpVersion(latest.value.version)
|
||||
form.forceUpdateMin = latest.value.force_update_min || ''
|
||||
form.releaseNotes = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载版本列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
function bumpVersion(v: string): string {
|
||||
const parts = v.split('.').map(Number)
|
||||
parts[parts.length - 1]++
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
function handleFileChange(file: UploadFile) {
|
||||
selectedFile.value = file.raw || null
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!form.version) {
|
||||
ElMessage.warning('请输入版本号')
|
||||
return
|
||||
}
|
||||
|
||||
publishing.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
if (selectedFile.value) {
|
||||
fd.append('file', selectedFile.value)
|
||||
}
|
||||
fd.append('version', form.version)
|
||||
fd.append('platform', 'android')
|
||||
fd.append('force_update_min', form.forceUpdateMin)
|
||||
fd.append('release_notes', form.releaseNotes)
|
||||
|
||||
await api.post('/api/v1/app/versions', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
|
||||
ElMessage.success(`版本 ${form.version} 发布成功!`)
|
||||
uploadRef.value?.clearFiles()
|
||||
selectedFile.value = null
|
||||
form.version = bumpVersion(form.version)
|
||||
form.releaseNotes = ''
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || e.message || '发布失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetLatest(row: Record<string, any>) {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('version', row.version)
|
||||
fd.append('platform', row.platform)
|
||||
fd.append('force_update_min', row.force_update_min || '')
|
||||
fd.append('release_notes', row.release_notes || '')
|
||||
await api.post('/api/v1/app/versions', fd)
|
||||
ElMessage.success(`已将 v${row.version} 设为最新版本`)
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(versionStr: string) {
|
||||
try {
|
||||
await api.delete(`/api/v1/app/versions/${versionStr}`)
|
||||
ElMessage.success('版本已删除')
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return '-'
|
||||
try {
|
||||
return new Date(d).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-version-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user