Show update status on admin dashboard

This commit is contained in:
RWDai
2026-05-13 10:28:18 +08:00
parent c90ed14a24
commit a03cab4a09
3 changed files with 170 additions and 2 deletions

View File

@@ -122,6 +122,77 @@
</template> </template>
</div> </div>
<!-- 管理员版本与更新状态 -->
<Card
v-if="isAdmin"
class="relative mt-6 overflow-hidden p-4 sm:p-5 border-book-cloth/30"
>
<div class="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-book-cloth/20 blur-3xl" />
<div class="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="mb-2 flex items-center gap-2">
<Badge
variant="outline"
class="uppercase tracking-[0.3em] text-[10px]"
>
Version
</Badge>
<Badge
v-if="updateStatus"
:variant="updateStatus.has_update ? 'default' : 'secondary'"
class="text-[10px]"
>
{{ updateStatusLabel }}
</Badge>
</div>
<h3 class="text-sm font-medium text-foreground">
系统版本
</h3>
<p class="mt-2 text-sm text-muted-foreground">
当前版本
<span class="font-mono text-foreground">
{{ updateStatus?.current_version || '加载中...' }}
</span>
<template v-if="updateStatus?.latest_version">
最新版本
<span class="font-mono text-foreground">
{{ updateStatus.latest_version }}
</span>
</template>
</p>
<p
v-if="updateStatus?.error"
class="mt-2 text-xs text-muted-foreground"
>
检查更新失败{{ updateStatus.error }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="loadingUpdateStatus"
@click="loadSystemUpdateStatus"
>
<RefreshCw
class="mr-2 h-3.5 w-3.5"
:class="loadingUpdateStatus ? 'animate-spin' : ''"
/>
重新检查
</Button>
<Button
v-if="updateStatus?.has_update && updateStatus.release_url"
size="sm"
@click="openReleasePage"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" />
查看更新
</Button>
</div>
</div>
</Card>
<!-- 管理员系统健康摘要 --> <!-- 管理员系统健康摘要 -->
<div <div
v-if="isAdmin && systemHealth" v-if="isAdmin && systemHealth"
@@ -792,6 +863,7 @@
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard' import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { getDateRangeFromPeriod } from '@/features/usage/composables' import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types' import type { DateRangeParams } from '@/features/usage/types'
import { announcementApi, type Announcement } from '@/api/announcements' import { announcementApi, type Announcement } from '@/api/announcements'
@@ -828,10 +900,13 @@ import {
Loader2, Loader2,
Clock, Clock,
Database, Database,
Shuffle Shuffle,
RefreshCw,
ExternalLink
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { parseDateLike } from '@/utils/date' import { parseDateLike } from '@/utils/date'
import { buildDashboardUpdateErrorStatus, describeDashboardUpdateStatus } from './dashboardUpdateStatus'
import { marked } from 'marked' import { marked } from 'marked'
import { sanitizeMarkdown } from '@/utils/sanitize' import { sanitizeMarkdown } from '@/utils/sanitize'
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js' import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
@@ -970,11 +1045,17 @@ const cacheStats = ref<{
} | null>(null) } | null>(null)
const userMonthlyCost = ref<number | null>(null) const userMonthlyCost = ref<number | null>(null)
const updateStatus = ref<CheckUpdateResponse | null>(null)
const loadingUpdateStatus = ref(false)
const hasCacheData = computed(() => const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0 cacheStats.value && cacheStats.value.total_cache_tokens > 0
) )
const updateStatusLabel = computed(() => {
return describeDashboardUpdateStatus(updateStatus.value)
})
const tokenBreakdown = ref<{ const tokenBreakdown = ref<{
input: number input: number
output: number output: number
@@ -1286,7 +1367,8 @@ onMounted(async () => {
await Promise.all([ await Promise.all([
loadDashboardData(), loadDashboardData(),
loadAnnouncements(), loadAnnouncements(),
loadDailyStats() loadDailyStats(),
loadSystemUpdateStatus()
]) ])
await nextTick() await nextTick()
setupTimelineResizeObserver() setupTimelineResizeObserver()
@@ -1342,6 +1424,24 @@ async function loadDashboardData() {
} }
} }
async function loadSystemUpdateStatus() {
if (!isAdmin.value) return
loadingUpdateStatus.value = true
try {
updateStatus.value = await adminApi.checkUpdate()
} catch (error) {
updateStatus.value = buildDashboardUpdateErrorStatus(updateStatus.value, error)
} finally {
loadingUpdateStatus.value = false
}
}
function openReleasePage() {
if (updateStatus.value?.release_url) {
window.open(updateStatus.value.release_url, '_blank', 'noopener,noreferrer')
}
}
async function loadDailyStats() { async function loadDailyStats() {
if (dailyStatsLoadPromise) { if (dailyStatsLoadPromise) {
hasPendingDailyStatsLoad = true hasPendingDailyStatsLoad = true

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { CheckUpdateResponse } from '@/api/admin'
import {
buildDashboardUpdateErrorStatus,
describeDashboardUpdateStatus,
} from '../dashboardUpdateStatus'
function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdateResponse {
return {
current_version: '0.7.0-rc27',
latest_version: null,
has_update: false,
release_url: null,
release_notes: null,
published_at: null,
error: null,
...overrides,
}
}
describe('dashboardUpdateStatus', () => {
it('describes loading and latest states', () => {
expect(describeDashboardUpdateStatus(null)).toBe('检查中')
expect(describeDashboardUpdateStatus(updateStatus())).toBe('已是最新')
})
it('prioritizes update availability over latest-version text', () => {
expect(describeDashboardUpdateStatus(updateStatus({
latest_version: 'v0.7.0-rc28',
has_update: true,
release_url: 'https://github.com/fawney19/Aether/releases/tag/v0.7.0-rc28',
}))).toBe('有新版本')
})
it('preserves the current version when building an error state', () => {
const status = buildDashboardUpdateErrorStatus(
updateStatus({ current_version: '0.7.0-rc28' }),
new Error('network down')
)
expect(status.current_version).toBe('0.7.0-rc28')
expect(status.has_update).toBe(false)
expect(status.error).toBe('network down')
})
})

View File

@@ -0,0 +1,23 @@
import type { CheckUpdateResponse } from '@/api/admin'
export function describeDashboardUpdateStatus(status: CheckUpdateResponse | null): string {
if (!status) return '检查中'
if (status.has_update) return '有新版本'
if (status.error) return '检查失败'
return '已是最新'
}
export function buildDashboardUpdateErrorStatus(
previousStatus: CheckUpdateResponse | null,
error: unknown
): CheckUpdateResponse {
return {
current_version: previousStatus?.current_version || '',
latest_version: null,
has_update: false,
release_url: null,
release_notes: null,
published_at: null,
error: error instanceof Error ? error.message : '检查更新失败'
}
}