From f2e62dd197346fd76306f8fe90e1f0aa69ce9f66 Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 8 Jan 2026 03:01:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A3=80=E6=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 /api/admin/system/check-update 接口,从 GitHub Tags 获取最新版本 - 前端新增 UpdateDialog 组件,管理员登录后自动检查更新并弹窗提示 - 同一会话内只检查一次,点击"稍后提醒"后 24 小时内不再提示 - CI 和 deploy.sh 自动生成 _version.py 版本文件 --- .github/workflows/docker-publish.yml | 23 +++ .gitignore | 3 + deploy.sh | 19 +++ frontend/src/api/admin.ts | 17 ++ .../src/components/common/UpdateDialog.vue | 112 +++++++++++++ frontend/src/layouts/MainLayout.vue | 61 ++++++++ src/_version.py | 34 ---- src/api/admin/system.py | 147 ++++++++++++++++-- 8 files changed, 373 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/common/UpdateDialog.vue delete mode 100644 src/_version.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2222097..675e305 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -146,10 +146,33 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=sha,prefix= + - name: Extract version from tag + id: version + run: | + # 从 tag 提取版本号,如 v0.2.5 -> 0.2.5 + VERSION="${GITHUB_REF#refs/tags/v}" + if [ "$VERSION" = "$GITHUB_REF" ]; then + # 不是 tag 触发,使用 git describe + VERSION=$(git describe --tags --always | sed 's/^v//') + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + - name: Update Dockerfile.app to use registry base image run: | sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app + - name: Generate version file + run: | + # 生成 _version.py 文件 + cat > src/_version.py << EOF + # Auto-generated by CI + __version__ = '${{ steps.version.outputs.version }}' + __version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit()) + version = __version__ + version_tuple = __version_tuple__ + EOF + - name: Build and push app image uses: docker/build-push-action@v5 with: diff --git a/.gitignore b/.gitignore index 9fc8a46..ab14ece 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ extracted_*.ts .deps-hash .code-hash .migration-hash + +# Version file (auto-generated by hatch-vcs) +src/_version.py diff --git a/deploy.sh b/deploy.sh index c0da4af..3ae428f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -88,9 +88,28 @@ build_base() { save_deps_hash } +# 生成版本文件 +generate_version_file() { + # 从 git 获取版本号 + local version + version=$(git describe --tags --always 2>/dev/null | sed 's/^v//') + if [ -z "$version" ]; then + version="unknown" + fi + echo ">>> Generating version file: $version" + cat > src/_version.py << EOF +# Auto-generated by deploy.sh - do not edit +__version__ = '$version' +__version_tuple__ = tuple(int(x) for x in '$version'.split('-')[0].split('.') if x.isdigit()) +version = __version__ +version_tuple = __version_tuple__ +EOF +} + # 构建应用镜像 build_app() { echo ">>> Building app image (code only)..." + generate_version_file docker build -f Dockerfile.app.local -t aether-app:latest . save_code_hash } diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 88ec952..955cb34 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -159,6 +159,15 @@ export interface EmailTemplateResetResponse { } } +// 检查更新响应 +export interface CheckUpdateResponse { + current_version: string + latest_version: string | null + has_update: boolean + release_url: string | null + error: string | null +} + // LDAP 配置响应 export interface LdapConfigResponse { server_url: string | null @@ -526,6 +535,14 @@ export const adminApi = { return response.data }, + // 检查系统更新 + async checkUpdate(): Promise { + const response = await apiClient.get( + '/api/admin/system/check-update' + ) + return response.data + }, + // LDAP 配置相关 // 获取 LDAP 配置 async getLdapConfig(): Promise { diff --git a/frontend/src/components/common/UpdateDialog.vue b/frontend/src/components/common/UpdateDialog.vue new file mode 100644 index 0000000..75f7b7d --- /dev/null +++ b/frontend/src/components/common/UpdateDialog.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index e48d36d..f9db4eb 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -295,6 +295,15 @@ + + + @@ -304,10 +313,12 @@ import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useDarkMode } from '@/composables/useDarkMode' import { isDemoMode } from '@/config/demo' +import { adminApi, type CheckUpdateResponse } from '@/api/admin' import Button from '@/components/ui/button.vue' import AppShell from '@/components/layout/AppShell.vue' import SidebarNav from '@/components/layout/SidebarNav.vue' import HeaderLogo from '@/components/HeaderLogo.vue' +import UpdateDialog from '@/components/common/UpdateDialog.vue' import { Home, Users, @@ -345,17 +356,67 @@ const showAuthError = ref(false) const mobileMenuOpen = ref(false) let authCheckInterval: number | null = null +// 更新检查相关 +const showUpdateDialog = ref(false) +const updateInfo = ref(null) + // 路由变化时自动关闭移动端菜单 watch(() => route.path, () => { mobileMenuOpen.value = false }) +// 检查是否应该显示更新提示 +function shouldShowUpdatePrompt(latestVersion: string): boolean { + const ignoreKey = 'aether_update_ignore' + const ignoreData = localStorage.getItem(ignoreKey) + if (!ignoreData) return true + + try { + const { version, until } = JSON.parse(ignoreData) + // 如果忽略的是同一版本且未过期,则不显示 + if (version === latestVersion && Date.now() < until) { + return false + } + } catch { + // 解析失败,显示提示 + } + return true +} + +// 检查更新 +async function checkForUpdate() { + // 只有管理员才检查更新 + if (authStore.user?.role !== 'admin') return + + // 同一会话内只检查一次 + const sessionKey = 'aether_update_checked' + if (sessionStorage.getItem(sessionKey)) return + sessionStorage.setItem(sessionKey, '1') + + try { + const result = await adminApi.checkUpdate() + if (result.has_update && result.latest_version) { + if (shouldShowUpdatePrompt(result.latest_version)) { + updateInfo.value = result + showUpdateDialog.value = true + } + } + } catch { + // 静默失败,不影响用户体验 + } +} + onMounted(() => { authCheckInterval = setInterval(() => { if (authStore.user && !authStore.token) { showAuthError.value = true } }, 5000) + + // 延迟检查更新,避免影响页面加载 + setTimeout(() => { + checkForUpdate() + }, 2000) }) onUnmounted(() => { diff --git a/src/_version.py b/src/_version.py deleted file mode 100644 index 40bb6b5..0000000 --- a/src/_version.py +++ /dev/null @@ -1,34 +0,0 @@ -# file generated by setuptools-scm -# don't change, don't track in version control - -__all__ = [ - "__version__", - "__version_tuple__", - "version", - "version_tuple", - "__commit_id__", - "commit_id", -] - -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple - from typing import Union - - VERSION_TUPLE = Tuple[Union[int, str], ...] - COMMIT_ID = Union[str, None] -else: - VERSION_TUPLE = object - COMMIT_ID = object - -version: str -__version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE -commit_id: COMMIT_ID -__commit_id__: COMMIT_ID - -__version__ = version = '0.2.5' -__version_tuple__ = version_tuple = (0, 2, 5) - -__commit_id__ = commit_id = None diff --git a/src/api/admin/system.py b/src/api/admin/system.py index aa4a698..0f48b60 100644 --- a/src/api/admin/system.py +++ b/src/api/admin/system.py @@ -42,6 +42,42 @@ def _get_version_from_git() -> str | None: return None +def _get_current_version() -> str: + """获取当前版本号""" + version = _get_version_from_git() + if version: + return version + try: + from src._version import __version__ + + return __version__ + except ImportError: + return "unknown" + + +def _parse_version(version_str: str) -> tuple: + """解析版本号为可比较的元组,支持 3-4 段版本号 + + 例如: + - '0.2.5' -> (0, 2, 5, 0) + - '0.2.5.1' -> (0, 2, 5, 1) + - 'v0.2.5-4-g1234567' -> (0, 2, 5, 0) + """ + import re + + version_str = version_str.lstrip("v") + main_version = re.split(r"[-+]", version_str)[0] + try: + parts = main_version.split(".") + # 标准化为 4 段,便于比较 + int_parts = [int(p) for p in parts] + while len(int_parts) < 4: + int_parts.append(0) + return tuple(int_parts[:4]) + except ValueError: + return (0, 0, 0, 0) + + @router.get("/version") async def get_system_version(): """ @@ -52,18 +88,111 @@ async def get_system_version(): **返回字段**: - `version`: 版本号字符串 """ - # 优先从 git 获取 - version = _get_version_from_git() - if version: - return {"version": version} + return {"version": _get_current_version()} + + +@router.get("/check-update") +async def check_update(): + """ + 检查系统更新 + + 从 GitHub Tags 获取最新版本并与当前版本对比。 + + **返回字段**: + - `current_version`: 当前版本号 + - `latest_version`: 最新版本号 + - `has_update`: 是否有更新可用 + - `release_url`: 最新版本的 GitHub 页面链接 + """ + import httpx + + from src.clients.http_client import HTTPClientPool + + current_version = _get_current_version() + github_repo = "Aethersailor/Aether" + github_tags_url = f"https://api.github.com/repos/{github_repo}/tags" - # 回退到静态版本文件 try: - from src._version import __version__ + async with HTTPClientPool.get_temp_client( + timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) + ) as client: + response = await client.get( + github_tags_url, + headers={ + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"Aether/{current_version}", + }, + params={"per_page": 10}, + ) - return {"version": __version__} - except ImportError: - return {"version": "unknown"} + if response.status_code != 200: + return { + "current_version": current_version, + "latest_version": None, + "has_update": False, + "release_url": None, + "error": f"GitHub API 返回错误: {response.status_code}", + } + + tags = response.json() + if not tags: + return { + "current_version": current_version, + "latest_version": None, + "has_update": False, + "release_url": None, + "error": None, + } + + # 找到最新的版本 tag(按版本号排序,而非时间) + version_tags = [] + for tag in tags: + tag_name = tag.get("name", "") + if tag_name.startswith("v") or tag_name[0].isdigit(): + version_tags.append((tag_name, _parse_version(tag_name))) + + if not version_tags: + return { + "current_version": current_version, + "latest_version": None, + "has_update": False, + "release_url": None, + "error": None, + } + + # 按版本号排序,取最大的 + version_tags.sort(key=lambda x: x[1], reverse=True) + latest_tag = version_tags[0][0] + latest_version = latest_tag.lstrip("v") + + current_tuple = _parse_version(current_version) + latest_tuple = _parse_version(latest_version) + has_update = latest_tuple > current_tuple + + return { + "current_version": current_version, + "latest_version": latest_version, + "has_update": has_update, + "release_url": f"https://github.com/{github_repo}/releases/tag/{latest_tag}", + "error": None, + } + + except httpx.TimeoutException: + return { + "current_version": current_version, + "latest_version": None, + "has_update": False, + "release_url": None, + "error": "检查更新超时", + } + except Exception as e: + return { + "current_version": current_version, + "latest_version": None, + "has_update": False, + "release_url": None, + "error": f"检查更新失败: {str(e)}", + } pipeline = ApiRequestPipeline()