mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: add version update flow
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -34,6 +34,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"http",
|
"http",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
|
|||||||
@@ -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"] }
|
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
semver = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
serde_path_to_error = "0.1"
|
serde_path_to_error = "0.1"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ base64.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
semver.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_path_to_error.workspace = true
|
serde_path_to_error.workspace = true
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use semver::Version;
|
||||||
use serde::{de, de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de, de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -640,10 +641,9 @@ pub fn build_admin_system_check_update_payload_with_release(
|
|||||||
latest_release: Option<AdminSystemUpdateRelease>,
|
latest_release: Option<AdminSystemUpdateRelease>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let has_update = latest_release.as_ref().is_some_and(|release| {
|
let has_update = latest_release
|
||||||
normalized_admin_system_version(&release.version)
|
.as_ref()
|
||||||
!= normalized_admin_system_version(¤t_version)
|
.is_some_and(|release| admin_system_update_available(¤t_version, &release.version));
|
||||||
});
|
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"current_version": current_version,
|
"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 {
|
fn normalized_admin_system_version(version: &str) -> String {
|
||||||
version
|
let trimmed = version.trim();
|
||||||
.trim()
|
trimmed
|
||||||
.strip_prefix('v')
|
.strip_prefix('v')
|
||||||
.unwrap_or(version.trim())
|
.or_else(|| trimmed.strip_prefix('V'))
|
||||||
|
.unwrap_or(trimmed)
|
||||||
.to_string()
|
.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(
|
pub fn build_admin_system_stats_payload(
|
||||||
total_users: u64,
|
total_users: u64,
|
||||||
active_users: u64,
|
active_users: u64,
|
||||||
@@ -2257,6 +2310,57 @@ mod tests {
|
|||||||
assert_eq!(payload["error"], serde_json::Value::Null);
|
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]
|
#[test]
|
||||||
fn parse_admin_system_config_import_request_accepts_supported_versions() {
|
fn parse_admin_system_config_import_request_accepts_supported_versions() {
|
||||||
let parsed = parse_admin_system_config_import_request(
|
let parsed = parse_admin_system_config_import_request(
|
||||||
|
|||||||
@@ -17,41 +17,26 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||||
<span class="px-3 py-1.5 rounded-lg bg-muted text-sm font-mono text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
v{{ currentVersion }}
|
最新版本
|
||||||
</span>
|
</p>
|
||||||
<svg
|
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
|
||||||
class="h-4 w-4 text-muted-foreground"
|
{{ formatDisplayVersion(latestVersion) }}
|
||||||
fill="none"
|
</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- Published At -->
|
|
||||||
<p
|
|
||||||
v-if="publishedAt"
|
|
||||||
class="text-xs text-muted-foreground mb-4"
|
|
||||||
>
|
|
||||||
发布于 {{ formattedPublishedAt }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Release Notes -->
|
<!-- Release Notes -->
|
||||||
<div
|
<div
|
||||||
v-if="releaseNotes"
|
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 class="text-left text-xs font-medium text-muted-foreground mb-2">
|
||||||
更新内容
|
更新内容
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +51,7 @@
|
|||||||
<!-- Description (fallback when no release notes) -->
|
<!-- Description (fallback when no release notes) -->
|
||||||
<p
|
<p
|
||||||
v-else
|
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>
|
</p>
|
||||||
@@ -97,6 +82,7 @@ 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 { formatDisplayVersion } from '@/utils/version'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
|||||||
172
frontend/src/components/common/VersionButton.vue
Normal file
172
frontend/src/components/common/VersionButton.vue
Normal 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>
|
||||||
@@ -114,6 +114,13 @@
|
|||||||
|
|
||||||
<!-- Right Actions -->
|
<!-- Right Actions -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<VersionButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
:status="versionStatus"
|
||||||
|
:loading="loadingVersionStatus"
|
||||||
|
@refresh="handleVersionRefresh"
|
||||||
|
@open-release="openVersionReleasePage"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
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' ? '深色模式' : '浅色模式'"
|
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||||
@@ -290,6 +297,13 @@
|
|||||||
id="header-actions-right"
|
id="header-actions-right"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
/>
|
/>
|
||||||
|
<VersionButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
:status="versionStatus"
|
||||||
|
:loading="loadingVersionStatus"
|
||||||
|
@refresh="handleVersionRefresh"
|
||||||
|
@open-release="openVersionReleasePage"
|
||||||
|
/>
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
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 SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||||
import UpdateDialog from '@/components/common/UpdateDialog.vue'
|
import UpdateDialog from '@/components/common/UpdateDialog.vue'
|
||||||
|
import VersionButton from '@/components/common/VersionButton.vue'
|
||||||
|
import { buildUpdateErrorStatus } from '@/utils/updateStatus'
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Users,
|
Users,
|
||||||
@@ -396,6 +412,7 @@ const moduleStore = useModuleStore()
|
|||||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle } = useSiteInfo()
|
||||||
const isDemo = computed(() => isDemoMode())
|
const isDemo = computed(() => isDemoMode())
|
||||||
|
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||||
|
|
||||||
const showAuthError = ref(false)
|
const showAuthError = ref(false)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
@@ -403,6 +420,9 @@ const mobileMenuOpen = ref(false)
|
|||||||
// 更新检查相关
|
// 更新检查相关
|
||||||
const showUpdateDialog = ref(false)
|
const showUpdateDialog = ref(false)
|
||||||
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
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, () => {
|
watch(() => route.path, () => {
|
||||||
@@ -427,26 +447,92 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
|
|||||||
return true
|
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() {
|
async function checkForUpdate() {
|
||||||
// 只有管理员才检查更新
|
// 只有管理员才检查更新
|
||||||
if (authStore.user?.role !== 'admin') return
|
if (!isAdmin.value) return
|
||||||
|
|
||||||
// 同一会话内只检查一次
|
// 同一会话内只检查一次
|
||||||
const sessionKey = 'aether_update_checked'
|
const sessionKey = 'aether_update_checked'
|
||||||
if (sessionStorage.getItem(sessionKey)) return
|
if (sessionStorage.getItem(sessionKey)) return
|
||||||
sessionStorage.setItem(sessionKey, '1')
|
sessionStorage.setItem(sessionKey, '1')
|
||||||
|
|
||||||
try {
|
const result = versionStatus.value ?? await loadVersionStatus()
|
||||||
const result = await adminApi.checkUpdate()
|
if (result?.has_update && result.latest_version) {
|
||||||
if (result.has_update && result.latest_version) {
|
if (shouldShowUpdatePrompt(result.latest_version)) {
|
||||||
if (shouldShowUpdatePrompt(result.latest_version)) {
|
updateInfo.value = result
|
||||||
updateInfo.value = result
|
showUpdateDialog.value = true
|
||||||
showUpdateDialog.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 静默失败,不影响用户体验
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,19 +567,31 @@ onMounted(() => {
|
|||||||
syncAuthNotice()
|
syncAuthNotice()
|
||||||
|
|
||||||
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
||||||
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
|
if (isAdmin.value && !moduleStore.loaded && !moduleStore.loading) {
|
||||||
moduleStore.fetchModules()
|
moduleStore.fetchModules()
|
||||||
}
|
}
|
||||||
|
void loadVersionStatus()
|
||||||
|
|
||||||
// 延迟检查更新,避免影响页面加载
|
// 延迟检查更新,避免影响页面加载
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
checkForUpdate()
|
void checkForUpdate()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
window.__aetherShowUpdateDialog = showDebugUpdateDialog
|
||||||
|
window.__aetherMockVersionStatus = showDebugVersionStatus
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('storage', handleStorageChange)
|
window.removeEventListener('storage', handleStorageChange)
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
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() {
|
async function handleRelogin() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { CheckUpdateResponse } from '@/api/admin'
|
import type { CheckUpdateResponse } from '@/api/admin'
|
||||||
import {
|
import {
|
||||||
buildDashboardUpdateErrorStatus,
|
buildUpdateErrorStatus,
|
||||||
describeDashboardUpdateStatus,
|
describeUpdateStatus,
|
||||||
} from '../dashboardUpdateStatus'
|
} from '../updateStatus'
|
||||||
|
|
||||||
function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdateResponse {
|
function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdateResponse {
|
||||||
return {
|
return {
|
||||||
@@ -18,14 +18,14 @@ function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('dashboardUpdateStatus', () => {
|
describe('updateStatus', () => {
|
||||||
it('describes loading and latest states', () => {
|
it('describes loading and latest states', () => {
|
||||||
expect(describeDashboardUpdateStatus(null)).toBe('检查中')
|
expect(describeUpdateStatus(null)).toBe('检查中')
|
||||||
expect(describeDashboardUpdateStatus(updateStatus())).toBe('已是最新')
|
expect(describeUpdateStatus(updateStatus())).toBe('已是最新')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prioritizes update availability over latest-version text', () => {
|
it('prioritizes update availability over latest-version text', () => {
|
||||||
expect(describeDashboardUpdateStatus(updateStatus({
|
expect(describeUpdateStatus(updateStatus({
|
||||||
latest_version: 'v0.7.0-rc28',
|
latest_version: 'v0.7.0-rc28',
|
||||||
has_update: true,
|
has_update: true,
|
||||||
release_url: 'https://github.com/fawney19/Aether/releases/tag/v0.7.0-rc28',
|
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', () => {
|
it('preserves the current version when building an error state', () => {
|
||||||
const status = buildDashboardUpdateErrorStatus(
|
const status = buildUpdateErrorStatus(
|
||||||
updateStatus({ current_version: '0.7.0-rc28' }),
|
updateStatus({ current_version: '0.7.0-rc28' }),
|
||||||
new Error('network down')
|
new Error('network down')
|
||||||
)
|
)
|
||||||
18
frontend/src/utils/__tests__/version.spec.ts
Normal file
18
frontend/src/utils/__tests__/version.spec.ts
Normal 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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { CheckUpdateResponse } from '@/api/admin'
|
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) return '检查中'
|
||||||
if (status.has_update) return '有新版本'
|
if (status.has_update) return '有新版本'
|
||||||
if (status.error) return '检查失败'
|
if (status.error) return '检查失败'
|
||||||
return '已是最新'
|
return '已是最新'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDashboardUpdateErrorStatus(
|
export function buildUpdateErrorStatus(
|
||||||
previousStatus: CheckUpdateResponse | null,
|
previousStatus: CheckUpdateResponse | null,
|
||||||
error: unknown
|
error: unknown
|
||||||
): CheckUpdateResponse {
|
): CheckUpdateResponse {
|
||||||
5
frontend/src/utils/version.ts
Normal file
5
frontend/src/utils/version.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -122,77 +122,6 @@
|
|||||||
</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"
|
||||||
@@ -863,7 +792,6 @@
|
|||||||
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'
|
||||||
@@ -901,12 +829,9 @@ import {
|
|||||||
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'
|
||||||
@@ -1045,17 +970,11 @@ 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
|
||||||
@@ -1367,8 +1286,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadDashboardData(),
|
loadDashboardData(),
|
||||||
loadAnnouncements(),
|
loadAnnouncements(),
|
||||||
loadDailyStats(),
|
loadDailyStats()
|
||||||
loadSystemUpdateStatus()
|
|
||||||
])
|
])
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setupTimelineResizeObserver()
|
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() {
|
async function loadDailyStats() {
|
||||||
if (dailyStatsLoadPromise) {
|
if (dailyStatsLoadPromise) {
|
||||||
hasPendingDailyStatsLoad = true
|
hasPendingDailyStatsLoad = true
|
||||||
|
|||||||
5
frontend/src/vite-env.d.ts
vendored
5
frontend/src/vite-env.d.ts
vendored
@@ -1,3 +1,8 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
__aetherShowUpdateDialog?: () => void
|
||||||
|
__aetherMockVersionStatus?: (hasUpdate?: boolean) => void
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user