mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 03:58:28 +08:00
feat: 添加版本更新检查功能
- 后端新增 /api/admin/system/check-update 接口,从 GitHub Tags 获取最新版本 - 前端新增 UpdateDialog 组件,管理员登录后自动检查更新并弹窗提示 - 同一会话内只检查一次,点击"稍后提醒"后 24 小时内不再提示 - CI 和 deploy.sh 自动生成 _version.py 版本文件
This commit is contained in:
23
.github/workflows/docker-publish.yml
vendored
23
.github/workflows/docker-publish.yml
vendored
@@ -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:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -224,3 +224,6 @@ extracted_*.ts
|
||||
.deps-hash
|
||||
.code-hash
|
||||
.migration-hash
|
||||
|
||||
# Version file (auto-generated by hatch-vcs)
|
||||
src/_version.py
|
||||
|
||||
19
deploy.sh
19
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
|
||||
}
|
||||
|
||||
@@ -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<CheckUpdateResponse> {
|
||||
const response = await apiClient.get<CheckUpdateResponse>(
|
||||
'/api/admin/system/check-update'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// LDAP 配置相关
|
||||
// 获取 LDAP 配置
|
||||
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||
|
||||
112
frontend/src/components/common/UpdateDialog.vue
Normal file
112
frontend/src/components/common/UpdateDialog.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="isOpen"
|
||||
size="md"
|
||||
title=""
|
||||
>
|
||||
<div class="flex flex-col items-center text-center py-2">
|
||||
<!-- Logo -->
|
||||
<HeaderLogo
|
||||
size="h-16 w-16"
|
||||
class-name="text-primary"
|
||||
/>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||
发现新版本
|
||||
</h2>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="px-3 py-1.5 rounded-lg bg-muted text-sm font-mono text-muted-foreground">
|
||||
v{{ currentVersion }}
|
||||
</span>
|
||||
<svg
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="px-3 py-1.5 rounded-lg bg-primary/10 text-sm font-mono font-medium text-primary">
|
||||
v{{ latestVersion }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-muted-foreground max-w-xs">
|
||||
新版本已发布,建议更新以获得最新功能和安全修复
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
@click="handleLater"
|
||||
>
|
||||
稍后提醒
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1"
|
||||
@click="handleViewRelease"
|
||||
>
|
||||
查看更新
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
currentVersion: string
|
||||
latestVersion: string
|
||||
releaseUrl: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
})
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
function handleLater() {
|
||||
// 记录忽略的版本,24小时内不再提醒
|
||||
const ignoreKey = 'aether_update_ignore'
|
||||
const ignoreData = {
|
||||
version: props.latestVersion,
|
||||
until: Date.now() + 24 * 60 * 60 * 1000 // 24小时
|
||||
}
|
||||
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleViewRelease() {
|
||||
if (props.releaseUrl) {
|
||||
window.open(props.releaseUrl, '_blank')
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -295,6 +295,15 @@
|
||||
</template>
|
||||
|
||||
<RouterView />
|
||||
|
||||
<!-- 更新提示弹窗 -->
|
||||
<UpdateDialog
|
||||
v-if="updateInfo"
|
||||
v-model="showUpdateDialog"
|
||||
:current-version="updateInfo.current_version"
|
||||
:latest-version="updateInfo.latest_version || ''"
|
||||
:release-url="updateInfo.release_url"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -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<CheckUpdateResponse | null>(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(() => {
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user