From fee1df2c2f10ed4b3290f41621a7eea4911c3b9f Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Wed, 1 Jul 2026 22:37:19 +0800 Subject: [PATCH] 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 --- backend/app/api/app_update.py | 193 ++++++++++++++++-- frontend/src/components/MainLayout.vue | 3 +- frontend/src/router/index.ts | 6 + frontend/src/views/AppVersionManage.vue | 248 ++++++++++++++++++++++++ 4 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 frontend/src/views/AppVersionManage.vue diff --git a/backend/app/api/app_update.py b/backend/app/api/app_update.py index 6da5357..5fb80a4 100644 --- a/backend/app/api/app_update.py +++ b/backend/app/api/app_update.py @@ -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) diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue index b825650..9860a8a 100644 --- a/frontend/src/components/MainLayout.vue +++ b/frontend/src/components/MainLayout.vue @@ -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(() => { { 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 }, ], }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2eecd33..2ee803b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 } } ] }) diff --git a/frontend/src/views/AppVersionManage.vue b/frontend/src/views/AppVersionManage.vue new file mode 100644 index 0000000..33be63f --- /dev/null +++ b/frontend/src/views/AppVersionManage.vue @@ -0,0 +1,248 @@ + + + + +