mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
285 lines
7.6 KiB
Vue
285 lines
7.6 KiB
Vue
<template>
|
|
<v-dialog v-model="visible" max-width="520" :persistent="blockingRecovery || restarting">
|
|
<v-card>
|
|
<v-card-title class="upgrade-recovery-title">
|
|
<span>{{ t('core.common.upgradeRecovery.title') }}</span>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<p class="mb-3">
|
|
{{
|
|
t('core.common.upgradeRecovery.description', {
|
|
coreVersion,
|
|
dashboardVersion,
|
|
})
|
|
}}
|
|
</p>
|
|
<v-alert type="warning" variant="tonal" density="comfortable" class="mb-3">
|
|
{{ t('core.common.upgradeRecovery.hint') }}
|
|
</v-alert>
|
|
<v-progress-linear
|
|
v-if="restarting"
|
|
indeterminate
|
|
color="primary"
|
|
class="mb-2"
|
|
/>
|
|
<div v-if="statusMessage" class="text-medium-emphasis">
|
|
{{ statusMessage }}
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn v-if="!blockingRecovery" variant="text" :disabled="restarting" @click="dismiss">
|
|
{{ t('core.common.upgradeRecovery.laterButton') }}
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
prepend-icon="mdi-restart"
|
|
:loading="restarting"
|
|
@click="restartCore"
|
|
>
|
|
{{ t('core.common.upgradeRecovery.restartButton') }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import axios, { type AxiosRequestConfig } from 'axios';
|
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
|
|
import {
|
|
UPGRADE_RECOVERY_EVENT,
|
|
UPGRADE_RECOVERY_TOKEN_KEY,
|
|
type ApiEnvelope,
|
|
type VersionData,
|
|
} from '@/api/v1';
|
|
import { useI18n } from '@/i18n/composables';
|
|
|
|
type StartTimeData = {
|
|
start_time?: number | string | null;
|
|
};
|
|
|
|
type RecoveryEventDetail = VersionData & {
|
|
blocking?: boolean;
|
|
};
|
|
|
|
const { t } = useI18n();
|
|
const route = useRoute();
|
|
|
|
const visible = ref(false);
|
|
const restarting = ref(false);
|
|
const blockingRecovery = ref(false);
|
|
const statusMessage = ref('');
|
|
const coreVersion = ref('');
|
|
const dashboardVersion = ref('');
|
|
const initialStartTime = ref<number | string | null>(null);
|
|
|
|
let restartTimer: ReturnType<typeof setInterval> | null = null;
|
|
let detecting = false;
|
|
const recoveryClient = axios.create();
|
|
|
|
function normalizeVersion(version?: string | null) {
|
|
return (version || '').trim().replace(/^v/i, '');
|
|
}
|
|
|
|
function displayVersion(version?: string | null) {
|
|
const normalized = normalizeVersion(version);
|
|
return normalized ? `v${normalized}` : 'unknown';
|
|
}
|
|
|
|
function versionsMismatch(core?: string | null, dashboard?: string | null) {
|
|
const normalizedCore = normalizeVersion(core);
|
|
const normalizedDashboard = normalizeVersion(dashboard);
|
|
return Boolean(
|
|
normalizedCore &&
|
|
normalizedDashboard &&
|
|
normalizedCore !== normalizedDashboard,
|
|
);
|
|
}
|
|
|
|
function isMissingApiKeyResponse(response: {
|
|
data?: { message?: string | null } | string;
|
|
}) {
|
|
const message =
|
|
typeof response.data === 'string'
|
|
? response.data
|
|
: response.data?.message || '';
|
|
return message.toLowerCase().includes('missing api key');
|
|
}
|
|
|
|
function getDismissKey() {
|
|
return `astrbot-upgrade-recovery-dismissed:${coreVersion.value}:${dashboardVersion.value}`;
|
|
}
|
|
|
|
function recoveryRequestConfig(validateStatus = false): AxiosRequestConfig {
|
|
const headers: Record<string, string> = {};
|
|
const token =
|
|
localStorage.getItem('token') ||
|
|
sessionStorage.getItem(UPGRADE_RECOVERY_TOKEN_KEY);
|
|
const locale = localStorage.getItem('astrbot-locale');
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
if (locale) {
|
|
headers['Accept-Language'] = locale;
|
|
}
|
|
return {
|
|
headers,
|
|
...(validateStatus ? { validateStatus: () => true } : {}),
|
|
};
|
|
}
|
|
|
|
async function fetchLegacyStartTime() {
|
|
const response = await recoveryClient.get<ApiEnvelope<StartTimeData>>(
|
|
'/api/stat/start-time',
|
|
recoveryRequestConfig(),
|
|
);
|
|
return response.data?.data?.start_time ?? null;
|
|
}
|
|
|
|
function clearRestartTimer() {
|
|
if (restartTimer !== null) {
|
|
clearInterval(restartTimer);
|
|
restartTimer = null;
|
|
}
|
|
}
|
|
|
|
function dismiss() {
|
|
sessionStorage.setItem(getDismissKey(), '1');
|
|
sessionStorage.removeItem(UPGRADE_RECOVERY_TOKEN_KEY);
|
|
blockingRecovery.value = false;
|
|
visible.value = false;
|
|
}
|
|
|
|
function waitForRestart() {
|
|
clearRestartTimer();
|
|
let attempts = 0;
|
|
restartTimer = setInterval(async () => {
|
|
attempts += 1;
|
|
try {
|
|
const nextStartTime = await fetchLegacyStartTime();
|
|
if (
|
|
nextStartTime !== null &&
|
|
String(nextStartTime) !== String(initialStartTime.value)
|
|
) {
|
|
clearRestartTimer();
|
|
sessionStorage.removeItem(UPGRADE_RECOVERY_TOKEN_KEY);
|
|
window.location.reload();
|
|
}
|
|
} catch (_error) {
|
|
// The backend may be temporarily unavailable during restart.
|
|
}
|
|
|
|
if (attempts >= 90) {
|
|
clearRestartTimer();
|
|
restarting.value = false;
|
|
statusMessage.value = t('core.common.upgradeRecovery.failed');
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
async function restartCore() {
|
|
restarting.value = true;
|
|
statusMessage.value = t('core.common.upgradeRecovery.restarting');
|
|
try {
|
|
initialStartTime.value =
|
|
initialStartTime.value ?? (await fetchLegacyStartTime());
|
|
await recoveryClient.post<ApiEnvelope<unknown>>(
|
|
'/api/stat/restart-core',
|
|
undefined,
|
|
recoveryRequestConfig(),
|
|
);
|
|
statusMessage.value = t('core.common.upgradeRecovery.waiting');
|
|
waitForRestart();
|
|
} catch (_error) {
|
|
restarting.value = false;
|
|
statusMessage.value = t('core.common.upgradeRecovery.failed');
|
|
}
|
|
}
|
|
|
|
async function showRecoveryDialog(versionData: VersionData, blocking = false) {
|
|
if (visible.value || restarting.value) {
|
|
return;
|
|
}
|
|
if (!versionsMismatch(versionData.version, versionData.dashboard_version)) {
|
|
return;
|
|
}
|
|
|
|
coreVersion.value = displayVersion(versionData.version);
|
|
dashboardVersion.value = displayVersion(versionData.dashboard_version);
|
|
if (!blocking && sessionStorage.getItem(getDismissKey())) {
|
|
return;
|
|
}
|
|
|
|
blockingRecovery.value = blocking;
|
|
initialStartTime.value = await fetchLegacyStartTime().catch(() => null);
|
|
visible.value = true;
|
|
}
|
|
|
|
function handleRecoveryEvent(event: Event) {
|
|
const versionData = (event as CustomEvent<RecoveryEventDetail>).detail || {};
|
|
void showRecoveryDialog(versionData, !!versionData.blocking);
|
|
}
|
|
|
|
async function detectUpgradeMismatch() {
|
|
if (detecting || visible.value || restarting.value) {
|
|
return;
|
|
}
|
|
detecting = true;
|
|
try {
|
|
const v1Response = await recoveryClient.get<ApiEnvelope<unknown>>(
|
|
'/api/v1/auth/setup-status',
|
|
recoveryRequestConfig(true),
|
|
);
|
|
if (!isMissingApiKeyResponse(v1Response)) {
|
|
return;
|
|
}
|
|
|
|
const legacyResponse = await recoveryClient.get<ApiEnvelope<VersionData>>(
|
|
'/api/stat/version',
|
|
recoveryRequestConfig(true),
|
|
);
|
|
if (legacyResponse.status === 401 || legacyResponse.status >= 400) {
|
|
return;
|
|
}
|
|
|
|
await showRecoveryDialog(legacyResponse.data?.data || {});
|
|
} catch (_error) {
|
|
// This recovery dialog is best-effort and should never block the app.
|
|
} finally {
|
|
detecting = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener(UPGRADE_RECOVERY_EVENT, handleRecoveryEvent);
|
|
void detectUpgradeMismatch();
|
|
});
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
() => {
|
|
void detectUpgradeMismatch();
|
|
},
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener(UPGRADE_RECOVERY_EVENT, handleRecoveryEvent);
|
|
clearRestartTimer();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.upgrade-recovery-title {
|
|
align-items: center;
|
|
display: flex;
|
|
white-space: normal;
|
|
word-break: break-word;
|
|
}
|
|
</style>
|