feat: add version update flow

This commit is contained in:
fawney19
2026-05-13 22:21:07 +08:00
parent 8b813f645f
commit acd44328d6
13 changed files with 451 additions and 160 deletions

1
Cargo.lock generated
View File

@@ -34,6 +34,7 @@ dependencies = [
"chrono",
"http",
"reqwest",
"semver",
"serde",
"serde_json",
"serde_path_to_error",

View File

@@ -81,6 +81,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams"] }
regex = "1"
rustls = { version = "0.23", features = ["ring"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_path_to_error = "0.1"

View File

@@ -18,6 +18,7 @@ base64.workspace = true
chrono.workspace = true
http.workspace = true
reqwest.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_path_to_error.workspace = true

View File

@@ -16,6 +16,7 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use semver::Version;
use serde::{de, de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
@@ -640,10 +641,9 @@ pub fn build_admin_system_check_update_payload_with_release(
latest_release: Option<AdminSystemUpdateRelease>,
error: Option<String>,
) -> serde_json::Value {
let has_update = latest_release.as_ref().is_some_and(|release| {
normalized_admin_system_version(&release.version)
!= normalized_admin_system_version(&current_version)
});
let has_update = latest_release
.as_ref()
.is_some_and(|release| admin_system_update_available(&current_version, &release.version));
json!({
"current_version": current_version,
@@ -657,13 +657,66 @@ pub fn build_admin_system_check_update_payload_with_release(
}
fn normalized_admin_system_version(version: &str) -> String {
version
.trim()
let trimmed = version.trim();
trimmed
.strip_prefix('v')
.unwrap_or(version.trim())
.or_else(|| trimmed.strip_prefix('V'))
.unwrap_or(trimmed)
.to_string()
}
fn admin_system_update_available(current_version: &str, latest_release_version: &str) -> bool {
match (
parse_admin_system_version_for_update(current_version),
parse_admin_system_version_for_update(latest_release_version),
) {
(Some(current), Some(latest)) => latest > current,
_ => false,
}
}
fn parse_admin_system_version_for_update(version: &str) -> Option<Version> {
let base = admin_system_release_base_version(version);
let normalized = normalize_admin_system_rc_prerelease(&base);
Version::parse(&normalized).ok()
}
fn admin_system_release_base_version(version: &str) -> String {
let normalized = normalized_admin_system_version(version);
let without_dirty = normalized.strip_suffix("-dirty").unwrap_or(&normalized);
git_describe_base_version(without_dirty)
.unwrap_or(without_dirty)
.to_string()
}
fn git_describe_base_version(version: &str) -> Option<&str> {
let (before_hash, hash) = version.rsplit_once("-g")?;
if hash.is_empty() || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let (base, commit_count) = before_hash.rsplit_once('-')?;
if commit_count.is_empty() || !commit_count.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some(base)
}
fn normalize_admin_system_rc_prerelease(version: &str) -> String {
let Some((core, prerelease)) = version.split_once('-') else {
return version.to_string();
};
let Some(rc_number) = prerelease.strip_prefix("rc") else {
return version.to_string();
};
if rc_number.is_empty() || !rc_number.chars().all(|ch| ch.is_ascii_digit()) {
return version.to_string();
}
format!("{core}-rc.{rc_number}")
}
pub fn build_admin_system_stats_payload(
total_users: u64,
active_users: u64,
@@ -2257,6 +2310,57 @@ mod tests {
assert_eq!(payload["error"], serde_json::Value::Null);
}
#[test]
fn build_admin_system_check_update_payload_ignores_git_describe_build_on_latest_release() {
let payload = build_admin_system_check_update_payload_with_release(
"0.7.0-rc28-11-g63149fe2-dirty".to_string(),
Some(AdminSystemUpdateRelease {
version: "v0.7.0-rc28".to_string(),
release_url: None,
release_notes: None,
published_at: None,
}),
None,
);
assert_eq!(payload["has_update"], false);
assert_eq!(payload["error"], serde_json::Value::Null);
}
#[test]
fn build_admin_system_check_update_payload_ignores_newer_local_release() {
let payload = build_admin_system_check_update_payload_with_release(
"0.7.0-rc29".to_string(),
Some(AdminSystemUpdateRelease {
version: "v0.7.0-rc28".to_string(),
release_url: None,
release_notes: None,
published_at: None,
}),
None,
);
assert_eq!(payload["has_update"], false);
assert_eq!(payload["error"], serde_json::Value::Null);
}
#[test]
fn build_admin_system_check_update_payload_compares_rc_versions_numerically() {
let payload = build_admin_system_check_update_payload_with_release(
"0.7.0-rc9".to_string(),
Some(AdminSystemUpdateRelease {
version: "v0.7.0-rc10".to_string(),
release_url: None,
release_notes: None,
published_at: None,
}),
None,
);
assert_eq!(payload["has_update"], true);
assert_eq!(payload["error"], serde_json::Value::Null);
}
#[test]
fn parse_admin_system_config_import_request_accepts_supported_versions() {
let parsed = parse_admin_system_config_import_request(

View File

@@ -17,41 +17,26 @@
</h2>
<!-- Version Info -->
<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">
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 class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
<p class="text-xs text-muted-foreground">
最新版本
</p>
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
{{ formatDisplayVersion(latestVersion) }}
</p>
</div>
<!-- Published At -->
<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"
class="w-full mt-3 mb-4"
>
<div
v-if="publishedAt"
class="text-left text-xs text-muted-foreground mb-2"
>
发布于 {{ formattedPublishedAt }}
</div>
<div class="text-left text-xs font-medium text-muted-foreground mb-2">
更新内容
</div>
@@ -66,7 +51,7 @@
<!-- Description (fallback when no release notes) -->
<p
v-else
class="text-sm text-muted-foreground max-w-xs mb-4"
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
>
新版本已发布建议更新以获得最新功能和安全修复
</p>
@@ -97,6 +82,7 @@ import { ref, watch, computed } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { formatDisplayVersion } from '@/utils/version'
import { marked } from 'marked'
import DOMPurify from 'dompurify'

View File

@@ -0,0 +1,172 @@
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-lg transition"
:class="buttonClass"
:title="buttonTitle"
aria-label="版本信息"
>
<Info
class="h-4 w-4"
:class="loading ? 'animate-pulse' : ''"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
:side-offset="8"
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
>
<div class="text-left">
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
<div>
<div class="text-xs font-semibold text-foreground">
版本信息
</div>
<div class="mt-0.5 text-[10px] uppercase tracking-[0.3em] text-muted-foreground">
System version
</div>
</div>
<span
class="rounded-full border px-2 py-0.5 text-[10px] font-semibold"
:class="statusPillClass"
>
{{ statusLabel }}
</span>
</div>
<div class="space-y-3 px-3 py-3">
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
<div>
<p class="text-xs text-muted-foreground">
当前版本
</p>
<p class="mt-1 break-all font-mono text-sm text-foreground">
{{ currentVersionLabel }}
</p>
</div>
<div
v-if="latestVersionLabel"
class="mt-2"
>
<p class="text-xs text-muted-foreground">
最新版本
</p>
<p class="mt-1 break-all font-mono text-sm text-foreground">
{{ latestVersionLabel }}
</p>
</div>
</div>
<p
v-if="status?.error"
class="text-xs text-muted-foreground"
>
检查更新失败{{ status.error }}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="flex-1"
:disabled="loading"
@click="handleRefresh"
>
<RefreshCw
class="mr-2 h-3.5 w-3.5"
:class="loading ? 'animate-spin' : ''"
/>
重新检查
</Button>
<Button
v-if="status?.has_update && status.release_url"
size="sm"
class="flex-1"
@click="handleOpenRelease"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" />
查看更新
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { CheckUpdateResponse } from '@/api/admin'
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
import { formatDisplayVersion } from '@/utils/version'
import { describeUpdateStatus } from '@/utils/updateStatus'
import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
const props = defineProps<{
status: CheckUpdateResponse | null
loading?: boolean
}>()
const emit = defineEmits<{
refresh: []
openRelease: []
}>()
const isOpen = ref(false)
const loading = computed(() => props.loading ?? false)
const buttonClass = computed(() => {
const classes = []
if (isOpen.value) {
classes.push('bg-muted/50')
} else {
classes.push('hover:bg-muted/50')
}
if (props.status?.has_update) {
classes.push('text-primary')
} else if (isOpen.value) {
classes.push('text-foreground')
} else {
classes.push('text-muted-foreground hover:text-foreground')
}
return classes
})
const statusLabel = computed(() => describeUpdateStatus(props.status))
const currentVersionLabel = computed(() => {
return props.status?.current_version
? formatDisplayVersion(props.status.current_version)
: '加载中...'
})
const latestVersionLabel = computed(() => {
return props.status?.latest_version
? formatDisplayVersion(props.status.latest_version)
: ''
})
const statusPillClass = computed(() => {
if (!props.status) return 'border-border/60 bg-background/70 text-muted-foreground'
if (props.status.has_update) return 'border-primary/20 bg-primary/10 text-primary'
if (props.status.error) return 'border-destructive/20 bg-destructive/10 text-destructive'
return 'border-border/60 bg-background/70 text-muted-foreground'
})
const buttonTitle = computed(() => {
if (!props.status) return '版本信息'
return `版本信息:${statusLabel.value}`
})
function handleRefresh() {
emit('refresh')
}
function handleOpenRelease() {
isOpen.value = false
emit('openRelease')
}
</script>

View File

@@ -114,6 +114,13 @@
<!-- Right Actions -->
<div class="flex items-center gap-3">
<VersionButton
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
/>
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
@@ -290,6 +297,13 @@
id="header-actions-right"
class="flex items-center"
/>
<VersionButton
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
/>
<!-- Theme Toggle -->
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@@ -352,6 +366,8 @@ 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 VersionButton from '@/components/common/VersionButton.vue'
import { buildUpdateErrorStatus } from '@/utils/updateStatus'
import {
Home,
Users,
@@ -396,6 +412,7 @@ const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const isDemo = computed(() => isDemoMode())
const isAdmin = computed(() => authStore.user?.role === 'admin')
const showAuthError = ref(false)
const mobileMenuOpen = ref(false)
@@ -403,6 +420,9 @@ const mobileMenuOpen = ref(false)
// 更新检查相关
const showUpdateDialog = ref(false)
const updateInfo = ref<CheckUpdateResponse | null>(null)
const versionStatus = ref<CheckUpdateResponse | null>(null)
const loadingVersionStatus = ref(false)
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
// 路由变化时自动关闭移动端菜单
watch(() => route.path, () => {
@@ -427,26 +447,92 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
return true
}
async function loadVersionStatus() {
if (!isAdmin.value) return null
if (versionStatusLoadPromise) return versionStatusLoadPromise
loadingVersionStatus.value = true
versionStatusLoadPromise = (async () => {
try {
versionStatus.value = await adminApi.checkUpdate()
return versionStatus.value
} catch (error) {
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
return versionStatus.value
} finally {
loadingVersionStatus.value = false
versionStatusLoadPromise = null
}
})()
return versionStatusLoadPromise
}
function handleVersionRefresh() {
void loadVersionStatus()
}
function openVersionReleasePage() {
if (versionStatus.value?.release_url) {
window.open(versionStatus.value.release_url, '_blank', 'noopener,noreferrer')
}
}
function showDebugUpdateDialog() {
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
updateInfo.value = {
current_version: currentVersion,
latest_version: 'v0.7.0-rc99',
has_update: true,
release_url: 'https://github.com/fawney19/Aether/releases',
release_notes: [
"### What's Changed",
'- 调整版本更新提示样式',
'- 修复开发分支版本误判',
'- 统一版本号显示格式',
].join('\n'),
published_at: new Date().toISOString(),
error: null,
}
showUpdateDialog.value = true
}
function showDebugVersionStatus(hasUpdate = true) {
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
versionStatus.value = {
current_version: currentVersion,
latest_version: hasUpdate ? 'v0.7.0-rc99' : currentVersion,
has_update: hasUpdate,
release_url: hasUpdate ? 'https://github.com/fawney19/Aether/releases' : null,
release_notes: hasUpdate
? [
"### What's Changed",
'- 调整版本更新提示样式',
'- 修复开发分支版本误判',
'- 统一版本号显示格式',
].join('\n')
: null,
published_at: hasUpdate ? new Date().toISOString() : null,
error: null,
}
}
// 检查更新
async function checkForUpdate() {
// 只有管理员才检查更新
if (authStore.user?.role !== 'admin') return
if (!isAdmin.value) 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
}
const result = versionStatus.value ?? await loadVersionStatus()
if (result?.has_update && result.latest_version) {
if (shouldShowUpdatePrompt(result.latest_version)) {
updateInfo.value = result
showUpdateDialog.value = true
}
} catch {
// 静默失败,不影响用户体验
}
}
@@ -481,19 +567,31 @@ onMounted(() => {
syncAuthNotice()
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
if (isAdmin.value && !moduleStore.loaded && !moduleStore.loading) {
moduleStore.fetchModules()
}
void loadVersionStatus()
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
checkForUpdate()
void checkForUpdate()
}, 2000)
if (import.meta.env.DEV) {
window.__aetherShowUpdateDialog = showDebugUpdateDialog
window.__aetherMockVersionStatus = showDebugVersionStatus
}
})
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange)
document.removeEventListener('visibilitychange', handleVisibilityChange)
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
delete window.__aetherShowUpdateDialog
}
if (import.meta.env.DEV && window.__aetherMockVersionStatus === showDebugVersionStatus) {
delete window.__aetherMockVersionStatus
}
})
async function handleRelogin() {

View File

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

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { formatDisplayVersion } from '../version'
describe('formatDisplayVersion', () => {
it('adds one v prefix to unprefixed versions', () => {
expect(formatDisplayVersion('0.7.0-rc28')).toBe('v0.7.0-rc28')
})
it('keeps existing v prefixes intact', () => {
expect(formatDisplayVersion('v0.7.0-rc28')).toBe('v0.7.0-rc28')
})
it('preserves git describe development suffixes', () => {
expect(formatDisplayVersion('0.7.0-rc28-11-g63149fe2-dirty')).toBe(
'v0.7.0-rc28-11-g63149fe2-dirty'
)
})
})

View File

@@ -1,13 +1,13 @@
import type { CheckUpdateResponse } from '@/api/admin'
export function describeDashboardUpdateStatus(status: CheckUpdateResponse | null): string {
export function describeUpdateStatus(status: CheckUpdateResponse | null): string {
if (!status) return '检查中'
if (status.has_update) return '有新版本'
if (status.error) return '检查失败'
return '已是最新'
}
export function buildDashboardUpdateErrorStatus(
export function buildUpdateErrorStatus(
previousStatus: CheckUpdateResponse | null,
error: unknown
): CheckUpdateResponse {

View File

@@ -0,0 +1,5 @@
export function formatDisplayVersion(version: string): string {
const normalized = version.trim()
if (!normalized) return ''
return normalized.startsWith('v') || normalized.startsWith('V') ? normalized : `v${normalized}`
}

View File

@@ -122,77 +122,6 @@
</template>
</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
v-if="isAdmin && systemHealth"
@@ -863,7 +792,6 @@
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
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 type { DateRangeParams } from '@/features/usage/types'
import { announcementApi, type Announcement } from '@/api/announcements'
@@ -901,12 +829,9 @@ import {
Clock,
Database,
Shuffle,
RefreshCw,
ExternalLink
} from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { parseDateLike } from '@/utils/date'
import { buildDashboardUpdateErrorStatus, describeDashboardUpdateStatus } from './dashboardUpdateStatus'
import { marked } from 'marked'
import { sanitizeMarkdown } from '@/utils/sanitize'
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
@@ -1045,17 +970,11 @@ const cacheStats = ref<{
} | null>(null)
const userMonthlyCost = ref<number | null>(null)
const updateStatus = ref<CheckUpdateResponse | null>(null)
const loadingUpdateStatus = ref(false)
const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0
)
const updateStatusLabel = computed(() => {
return describeDashboardUpdateStatus(updateStatus.value)
})
const tokenBreakdown = ref<{
input: number
output: number
@@ -1367,8 +1286,7 @@ onMounted(async () => {
await Promise.all([
loadDashboardData(),
loadAnnouncements(),
loadDailyStats(),
loadSystemUpdateStatus()
loadDailyStats()
])
await nextTick()
setupTimelineResizeObserver()
@@ -1424,24 +1342,6 @@ 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() {
if (dailyStatsLoadPromise) {
hasPendingDailyStatsLoad = true

View File

@@ -1,3 +1,8 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
interface Window {
__aetherShowUpdateDialog?: () => void
__aetherMockVersionStatus?: (hasUpdate?: boolean) => void
}