mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-14 05:25:19 +08:00
feat(update): 增强更新检查功能,展示发布日志和发布时间
- 后端从 GitHub Tags API 改为 Releases API,获取更丰富的发布信息 - 新增 release_notes 和 published_at 字段 - 前端更新对话框展示发布时间和 Markdown 格式的更新日志 - 使用 DOMPurify 对 Markdown 渲染结果进行 XSS 防护 - 简化 GlobalModel 缓存失效逻辑,合并同步/异步调用
This commit is contained in:
@@ -166,6 +166,8 @@ export interface CheckUpdateResponse {
|
|||||||
latest_version: string | null
|
latest_version: string | null
|
||||||
has_update: boolean
|
has_update: boolean
|
||||||
release_url: string | null
|
release_url: string | null
|
||||||
|
release_notes: string | null
|
||||||
|
published_at: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<span class="px-3 py-1.5 rounded-lg bg-muted text-sm font-mono text-muted-foreground">
|
<span class="px-3 py-1.5 rounded-lg bg-muted text-sm font-mono text-muted-foreground">
|
||||||
v{{ currentVersion }}
|
v{{ currentVersion }}
|
||||||
</span>
|
</span>
|
||||||
@@ -39,8 +39,33 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Published At -->
|
||||||
<p class="text-sm text-muted-foreground max-w-xs">
|
<p
|
||||||
|
v-if="publishedAt"
|
||||||
|
class="text-xs text-muted-foreground mb-4"
|
||||||
|
>
|
||||||
|
发布于 {{ formattedPublishedAt }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Release Notes -->
|
||||||
|
<div
|
||||||
|
v-if="releaseNotes"
|
||||||
|
class="w-full mt-2 mb-4"
|
||||||
|
>
|
||||||
|
<div class="text-left text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
更新内容
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full max-h-48 overflow-y-auto rounded-lg bg-muted/50 p-3 text-left text-sm text-foreground/80 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0"
|
||||||
|
v-html="renderedReleaseNotes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (fallback when no release notes) -->
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-sm text-muted-foreground max-w-xs mb-4"
|
||||||
|
>
|
||||||
新版本已发布,建议更新以获得最新功能和安全修复
|
新版本已发布,建议更新以获得最新功能和安全修复
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,16 +91,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { Dialog } from '@/components/ui'
|
import { Dialog } from '@/components/ui'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
latestVersion: string
|
latestVersion: string
|
||||||
releaseUrl: string | null
|
releaseUrl: string | null
|
||||||
|
releaseNotes: string | null
|
||||||
|
publishedAt: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -92,6 +121,37 @@ watch(isOpen, (val) => {
|
|||||||
emit('update:modelValue', val)
|
emit('update:modelValue', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 格式化发布时间
|
||||||
|
const formattedPublishedAt = computed(() => {
|
||||||
|
if (!props.publishedAt) return ''
|
||||||
|
try {
|
||||||
|
const date = new Date(props.publishedAt)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return props.publishedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 渲染 Markdown 格式的 Release Notes(使用 DOMPurify 防止 XSS)
|
||||||
|
const renderedReleaseNotes = computed(() => {
|
||||||
|
if (!props.releaseNotes) return ''
|
||||||
|
try {
|
||||||
|
const html = marked.parse(props.releaseNotes, { async: false }) as string
|
||||||
|
return DOMPurify.sanitize(html)
|
||||||
|
} catch {
|
||||||
|
// 如果 markdown 解析失败,返回原始文本(转义 HTML)
|
||||||
|
return props.releaseNotes
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleLater() {
|
function handleLater() {
|
||||||
// 记录忽略的版本,24小时内不再提醒
|
// 记录忽略的版本,24小时内不再提醒
|
||||||
const ignoreKey = 'aether_update_ignore'
|
const ignoreKey = 'aether_update_ignore'
|
||||||
|
|||||||
@@ -303,6 +303,8 @@
|
|||||||
:current-version="updateInfo.current_version"
|
:current-version="updateInfo.current_version"
|
||||||
:latest-version="updateInfo.latest_version || ''"
|
:latest-version="updateInfo.latest_version || ''"
|
||||||
:release-url="updateInfo.release_url"
|
:release-url="updateInfo.release_url"
|
||||||
|
:release-notes="updateInfo.release_notes"
|
||||||
|
:published-at="updateInfo.published_at"
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -391,7 +391,6 @@ class AdminUpdateGlobalModelAdapter(AdminApiAdapter):
|
|||||||
raise InvalidRequestException("该模型正在被其他操作更新,请稍后重试")
|
raise InvalidRequestException("该模型正在被其他操作更新,请稍后重试")
|
||||||
raise
|
raise
|
||||||
old_model_name = old_global_model.name if old_global_model else None
|
old_model_name = old_global_model.name if old_global_model else None
|
||||||
new_model_name = self.payload.name if self.payload.name else old_model_name
|
|
||||||
|
|
||||||
# 执行更新(此时仍持有行锁)
|
# 执行更新(此时仍持有行锁)
|
||||||
global_model = GlobalModelService.update_global_model(
|
global_model = GlobalModelService.update_global_model(
|
||||||
@@ -407,13 +406,8 @@ class AdminUpdateGlobalModelAdapter(AdminApiAdapter):
|
|||||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||||
|
|
||||||
cache_service = get_cache_invalidation_service()
|
cache_service = get_cache_invalidation_service()
|
||||||
# 同步清理新旧两个名称的缓存(防止名称变更时的竞态)
|
|
||||||
if old_model_name:
|
if old_model_name:
|
||||||
cache_service.on_global_model_changed(old_model_name, self.global_model_id)
|
await cache_service.on_global_model_changed(old_model_name, self.global_model_id)
|
||||||
if new_model_name and new_model_name != old_model_name:
|
|
||||||
cache_service.on_global_model_changed(new_model_name, self.global_model_id)
|
|
||||||
# 异步失效更多缓存
|
|
||||||
await cache_service.on_global_model_changed_async(global_model.name, global_model.id)
|
|
||||||
|
|
||||||
return GlobalModelResponse.model_validate(global_model)
|
return GlobalModelResponse.model_validate(global_model)
|
||||||
|
|
||||||
@@ -461,8 +455,7 @@ class AdminDeleteGlobalModelAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
cache_service = get_cache_invalidation_service()
|
cache_service = get_cache_invalidation_service()
|
||||||
if model_name:
|
if model_name:
|
||||||
cache_service.on_global_model_changed(model_name, model_id)
|
await cache_service.on_global_model_changed(model_name, model_id)
|
||||||
await cache_service.on_global_model_changed_async(model_name, model_id)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -96,13 +96,15 @@ async def check_update():
|
|||||||
"""
|
"""
|
||||||
检查系统更新
|
检查系统更新
|
||||||
|
|
||||||
从 GitHub Tags 获取最新版本并与当前版本对比。
|
从 GitHub Releases 获取最新版本并与当前版本对比。
|
||||||
|
|
||||||
**返回字段**:
|
**返回字段**:
|
||||||
- `current_version`: 当前版本号
|
- `current_version`: 当前版本号
|
||||||
- `latest_version`: 最新版本号
|
- `latest_version`: 最新版本号
|
||||||
- `has_update`: 是否有更新可用
|
- `has_update`: 是否有更新可用
|
||||||
- `release_url`: 最新版本的 GitHub 页面链接
|
- `release_url`: 最新版本的 GitHub 页面链接
|
||||||
|
- `release_notes`: 更新日志 (Markdown 格式)
|
||||||
|
- `published_at`: 发布时间 (ISO 8601 格式)
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -110,14 +112,25 @@ async def check_update():
|
|||||||
|
|
||||||
current_version = _get_current_version()
|
current_version = _get_current_version()
|
||||||
github_repo = "Aethersailor/Aether"
|
github_repo = "Aethersailor/Aether"
|
||||||
github_tags_url = f"https://api.github.com/repos/{github_repo}/tags"
|
github_releases_url = f"https://api.github.com/repos/{github_repo}/releases"
|
||||||
|
|
||||||
|
def _make_empty_response(error: str | None = None):
|
||||||
|
return {
|
||||||
|
"current_version": current_version,
|
||||||
|
"latest_version": None,
|
||||||
|
"has_update": False,
|
||||||
|
"release_url": None,
|
||||||
|
"release_notes": None,
|
||||||
|
"published_at": None,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with HTTPClientPool.get_temp_client(
|
async with HTTPClientPool.get_temp_client(
|
||||||
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
|
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
github_tags_url,
|
github_releases_url,
|
||||||
headers={
|
headers={
|
||||||
"Accept": "application/vnd.github.v3+json",
|
"Accept": "application/vnd.github.v3+json",
|
||||||
"User-Agent": f"Aether/{current_version}",
|
"User-Agent": f"Aether/{current_version}",
|
||||||
@@ -126,43 +139,29 @@ async def check_update():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
return {
|
return _make_empty_response(f"GitHub API 返回错误: {response.status_code}")
|
||||||
"current_version": current_version,
|
|
||||||
"latest_version": None,
|
|
||||||
"has_update": False,
|
|
||||||
"release_url": None,
|
|
||||||
"error": f"GitHub API 返回错误: {response.status_code}",
|
|
||||||
}
|
|
||||||
|
|
||||||
tags = response.json()
|
releases = response.json()
|
||||||
if not tags:
|
if not releases:
|
||||||
return {
|
return _make_empty_response()
|
||||||
"current_version": current_version,
|
|
||||||
"latest_version": None,
|
|
||||||
"has_update": False,
|
|
||||||
"release_url": None,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 找到最新的版本 tag(按版本号排序,而非时间)
|
# 找到最新的正式 release(排除 prerelease 和 draft,按版本号排序)
|
||||||
version_tags = []
|
valid_releases = []
|
||||||
for tag in tags:
|
for release in releases:
|
||||||
tag_name = tag.get("name", "")
|
if release.get("prerelease") or release.get("draft"):
|
||||||
if tag_name.startswith("v") or tag_name[0].isdigit():
|
continue
|
||||||
version_tags.append((tag_name, _parse_version(tag_name)))
|
tag_name = release.get("tag_name", "")
|
||||||
|
if tag_name.startswith("v") or (tag_name and tag_name[0].isdigit()):
|
||||||
|
valid_releases.append((release, _parse_version(tag_name)))
|
||||||
|
|
||||||
if not version_tags:
|
if not valid_releases:
|
||||||
return {
|
return _make_empty_response()
|
||||||
"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)
|
valid_releases.sort(key=lambda x: x[1], reverse=True)
|
||||||
latest_tag = version_tags[0][0]
|
latest_release = valid_releases[0][0]
|
||||||
|
|
||||||
|
latest_tag = latest_release.get("tag_name", "")
|
||||||
latest_version = latest_tag.lstrip("v")
|
latest_version = latest_tag.lstrip("v")
|
||||||
|
|
||||||
current_tuple = _parse_version(current_version)
|
current_tuple = _parse_version(current_version)
|
||||||
@@ -173,26 +172,17 @@ async def check_update():
|
|||||||
"current_version": current_version,
|
"current_version": current_version,
|
||||||
"latest_version": latest_version,
|
"latest_version": latest_version,
|
||||||
"has_update": has_update,
|
"has_update": has_update,
|
||||||
"release_url": f"https://github.com/{github_repo}/releases/tag/{latest_tag}",
|
"release_url": latest_release.get("html_url")
|
||||||
|
or f"https://github.com/{github_repo}/releases/tag/{latest_tag}",
|
||||||
|
"release_notes": latest_release.get("body"),
|
||||||
|
"published_at": latest_release.get("published_at"),
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return {
|
return _make_empty_response("检查更新超时")
|
||||||
"current_version": current_version,
|
|
||||||
"latest_version": None,
|
|
||||||
"has_update": False,
|
|
||||||
"release_url": None,
|
|
||||||
"error": "检查更新超时",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return _make_empty_response(f"检查更新失败: {str(e)}")
|
||||||
"current_version": current_version,
|
|
||||||
"latest_version": None,
|
|
||||||
"has_update": False,
|
|
||||||
"release_url": None,
|
|
||||||
"error": f"检查更新失败: {str(e)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|||||||
Reference in New Issue
Block a user