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:
2026-07-01 22:37:19 +08:00
parent 92ae0b5e46
commit fee1df2c2f
4 changed files with 433 additions and 17 deletions

View File

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

View File

@@ -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 },
],
},
]

View File

@@ -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 }
}
]
})

View 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>