| <template> |
| <div class="relative"> |
| |
| <template v-if="isAdmin"> |
| <button |
| @click="toggleDropdown" |
| class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors" |
| :class="[ |
| hasUpdate |
| ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50' |
| : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700' |
| ]" |
| :title="hasUpdate ? t('version.updateAvailable') : t('version.upToDate')" |
| > |
| <span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span> |
| <span |
| v-else |
| class="h-3 w-12 animate-pulse rounded bg-gray-200 font-medium dark:bg-dark-600" |
| ></span> |
| |
| <span v-if="hasUpdate" class="relative flex h-2 w-2"> |
| <span |
| class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" |
| ></span> |
| <span class="relative inline-flex h-2 w-2 rounded-full bg-amber-500"></span> |
| </span> |
| </button> |
| |
| |
| <transition name="dropdown"> |
| <div |
| v-if="dropdownOpen" |
| ref="dropdownRef" |
| class="absolute left-0 z-50 mt-2 w-64 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800" |
| > |
| |
| <div |
| class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-dark-700" |
| > |
| <span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ |
| t('version.currentVersion') |
| }}</span> |
| <button |
| @click="refreshVersion(true)" |
| class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-dark-200" |
| :disabled="loading" |
| :title="t('version.refresh')" |
| > |
| <Icon |
| name="refresh" |
| size="sm" |
| :stroke-width="2" |
| :class="{ 'animate-spin': loading }" |
| /> |
| </button> |
| </div> |
| |
| <div class="p-4"> |
| |
| <div v-if="loading" class="flex items-center justify-center py-6"> |
| <svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"> |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| </div> |
| |
| |
| <template v-else> |
| |
| <div class="mb-4 text-center"> |
| <div class="inline-flex items-center gap-2"> |
| <span |
| v-if="currentVersion" |
| class="text-2xl font-bold text-gray-900 dark:text-white" |
| >v{{ currentVersion }}</span |
| > |
| <span v-else class="text-2xl font-bold text-gray-400 dark:text-dark-500">--</span> |
| |
| <span |
| v-if="!hasUpdate" |
| class="flex h-5 w-5 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30" |
| > |
| <svg |
| class="h-3 w-3 text-green-600 dark:text-green-400" |
| fill="currentColor" |
| viewBox="0 0 20 20" |
| > |
| <path |
| fill-rule="evenodd" |
| d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| </span> |
| </div> |
| <p class="mt-1 text-xs text-gray-500 dark:text-dark-400"> |
| {{ |
| hasUpdate |
| ? t('version.latestVersion') + ': v' + latestVersion |
| : t('version.upToDate') |
| }} |
| </p> |
| </div> |
| |
| |
| <div v-if="updateError" class="space-y-2"> |
| <div |
| class="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-900/20" |
| > |
| <div |
| class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50" |
| > |
| <Icon |
| name="x" |
| size="sm" |
| :stroke-width="2" |
| class="text-red-600 dark:text-red-400" |
| /> |
| </div> |
| <div class="min-w-0 flex-1"> |
| <p class="text-sm font-medium text-red-700 dark:text-red-300"> |
| {{ t('version.updateFailed') }} |
| </p> |
| <p class="truncate text-xs text-red-600/70 dark:text-red-400/70"> |
| {{ updateError }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <button |
| @click="handleUpdate" |
| :disabled="updating" |
| class="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50" |
| > |
| {{ t('version.retry') }} |
| </button> |
| </div> |
| |
| |
| <div v-else-if="updateSuccess && needRestart" class="space-y-2"> |
| <div |
| class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800/50 dark:bg-green-900/20" |
| > |
| <div |
| class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50" |
| > |
| <svg |
| class="h-4 w-4 text-green-600 dark:text-green-400" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="2" |
| > |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> |
| </svg> |
| </div> |
| <div class="min-w-0 flex-1"> |
| <p class="text-sm font-medium text-green-700 dark:text-green-300"> |
| {{ t('version.updateComplete') }} |
| </p> |
| <p class="text-xs text-green-600/70 dark:text-green-400/70"> |
| {{ t('version.restartRequired') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <button |
| @click="handleRestart" |
| :disabled="restarting" |
| class="flex w-full items-center justify-center gap-2 rounded-lg bg-green-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50" |
| > |
| <svg |
| v-if="restarting" |
| class="h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <svg |
| v-else |
| class="h-4 w-4" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="2" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" |
| /> |
| </svg> |
| <template v-if="restarting"> |
| <span>{{ t('version.restarting') }}</span> |
| <span v-if="restartCountdown > 0" class="tabular-nums" |
| >({{ restartCountdown }}s)</span |
| > |
| </template> |
| <span v-else>{{ t('version.restartNow') }}</span> |
| </button> |
| </div> |
| |
| |
| <div v-else-if="hasUpdate && !isReleaseBuild" class="space-y-2"> |
| <a |
| v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'" |
| :href="releaseInfo.html_url" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="group flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 transition-colors hover:bg-amber-100 dark:border-amber-800/50 dark:bg-amber-900/20 dark:hover:bg-amber-900/30" |
| > |
| <div |
| class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50" |
| > |
| <Icon |
| name="download" |
| size="sm" |
| :stroke-width="2" |
| class="text-amber-600 dark:text-amber-400" |
| /> |
| </div> |
| <div class="min-w-0 flex-1"> |
| <p class="text-sm font-medium text-amber-700 dark:text-amber-300"> |
| {{ t('version.updateAvailable') }} |
| </p> |
| <p class="text-xs text-amber-600/70 dark:text-amber-400/70"> |
| v{{ latestVersion }} |
| </p> |
| </div> |
| <svg |
| class="h-4 w-4 text-amber-500 transition-transform group-hover:translate-x-0.5 dark:text-amber-400" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="2" |
| > |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /> |
| </svg> |
| </a> |
| |
| <div |
| class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-2 dark:border-blue-800/50 dark:bg-blue-900/20" |
| > |
| <svg |
| class="h-3.5 w-3.5 flex-shrink-0 text-blue-500 dark:text-blue-400" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="2" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" |
| /> |
| </svg> |
| <p class="text-xs text-blue-600 dark:text-blue-400"> |
| {{ t('version.sourceModeHint') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div v-else-if="hasUpdate && isReleaseBuild" class="space-y-2"> |
| |
| <div |
| class="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20" |
| > |
| <div |
| class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50" |
| > |
| <Icon |
| name="download" |
| size="sm" |
| :stroke-width="2" |
| class="text-amber-600 dark:text-amber-400" |
| /> |
| </div> |
| <div class="min-w-0 flex-1"> |
| <p class="text-sm font-medium text-amber-700 dark:text-amber-300"> |
| {{ t('version.updateAvailable') }} |
| </p> |
| <p class="text-xs text-amber-600/70 dark:text-amber-400/70"> |
| v{{ latestVersion }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <button |
| @click="handleUpdate" |
| :disabled="updating" |
| class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50" |
| > |
| <svg v-if="updating" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="download" size="sm" :stroke-width="2" /> |
| {{ updating ? t('version.updating') : t('version.updateNow') }} |
| </button> |
| |
| |
| <a |
| v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'" |
| :href="releaseInfo.html_url" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="flex items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200" |
| > |
| {{ t('version.viewChangelog') }} |
| <Icon name="externalLink" size="xs" :stroke-width="2" /> |
| </a> |
| </div> |
| |
| |
| <a |
| v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'" |
| :href="releaseInfo.html_url" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200" |
| > |
| <svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> |
| <path |
| fill-rule="evenodd" |
| clip-rule="evenodd" |
| d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" |
| /> |
| </svg> |
| {{ t('version.viewRelease') }} |
| </a> |
| </template> |
| </div> |
| </div> |
| </transition> |
| </template> |
| |
| |
| <span v-else-if="version" class="text-xs text-gray-500 dark:text-dark-400"> |
| v{{ version }} |
| </span> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, onMounted, onBeforeUnmount } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAuthStore, useAppStore } from '@/stores' |
| import { performUpdate, restartService } from '@/api/admin/system' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const { t } = useI18n() |
| |
| const props = defineProps<{ |
| version?: string |
| }>() |
| |
| const authStore = useAuthStore() |
| const appStore = useAppStore() |
| |
| const isAdmin = computed(() => authStore.isAdmin) |
| |
| const dropdownOpen = ref(false) |
| const dropdownRef = ref<HTMLElement | null>(null) |
| |
| |
| const loading = computed(() => appStore.versionLoading) |
| const currentVersion = computed(() => appStore.currentVersion || props.version || '') |
| const latestVersion = computed(() => appStore.latestVersion) |
| const hasUpdate = computed(() => appStore.hasUpdate) |
| const releaseInfo = computed(() => appStore.releaseInfo) |
| const buildType = computed(() => appStore.buildType) |
| |
| |
| const updating = ref(false) |
| const restarting = ref(false) |
| const needRestart = ref(false) |
| const updateError = ref('') |
| const updateSuccess = ref(false) |
| const restartCountdown = ref(0) |
| |
| |
| const isReleaseBuild = computed(() => buildType.value === 'release') |
| |
| function toggleDropdown() { |
| dropdownOpen.value = !dropdownOpen.value |
| } |
| |
| function closeDropdown() { |
| dropdownOpen.value = false |
| } |
| |
| async function refreshVersion(force = true) { |
| if (!isAdmin.value) return |
| |
| |
| updateError.value = '' |
| updateSuccess.value = false |
| needRestart.value = false |
| |
| await appStore.fetchVersion(force) |
| } |
| |
| async function handleUpdate() { |
| if (updating.value) return |
| |
| updating.value = true |
| updateError.value = '' |
| updateSuccess.value = false |
| |
| try { |
| const result = await performUpdate() |
| updateSuccess.value = true |
| needRestart.value = result.need_restart |
| |
| appStore.clearVersionCache() |
| } catch (error: unknown) { |
| const err = error as { response?: { data?: { message?: string } }; message?: string } |
| updateError.value = err.response?.data?.message || err.message || t('version.updateFailed') |
| } finally { |
| updating.value = false |
| } |
| } |
| |
| async function handleRestart() { |
| if (restarting.value) return |
| |
| restarting.value = true |
| restartCountdown.value = 8 |
| |
| try { |
| await restartService() |
| |
| } catch (error) { |
| |
| console.log('Service restarting...') |
| } |
| |
| |
| const countdownInterval = setInterval(() => { |
| restartCountdown.value-- |
| if (restartCountdown.value <= 0) { |
| clearInterval(countdownInterval) |
| |
| checkServiceAndReload() |
| } |
| }, 1000) |
| } |
| |
| async function checkServiceAndReload() { |
| const maxRetries = 5 |
| const retryDelay = 1000 |
| |
| for (let i = 0; i < maxRetries; i++) { |
| try { |
| const response = await fetch('/health', { |
| method: 'GET', |
| cache: 'no-cache' |
| }) |
| if (response.ok) { |
| |
| window.location.reload() |
| return |
| } |
| } catch { |
| |
| } |
| |
| if (i < maxRetries - 1) { |
| await new Promise((resolve) => setTimeout(resolve, retryDelay)) |
| } |
| } |
| |
| |
| window.location.reload() |
| } |
| |
| function handleClickOutside(event: MouseEvent) { |
| const target = event.target as Node |
| const button = (event.target as Element).closest('button') |
| if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) { |
| closeDropdown() |
| } |
| } |
| |
| onMounted(() => { |
| if (isAdmin.value) { |
| |
| appStore.fetchVersion(false) |
| } |
| document.addEventListener('click', handleClickOutside) |
| }) |
| |
| onBeforeUnmount(() => { |
| document.removeEventListener('click', handleClickOutside) |
| }) |
| </script> |
| |
| <style scoped> |
| .dropdown-enter-active, |
| .dropdown-leave-active { |
| transition: all 0.2s ease; |
| } |
| |
| .dropdown-enter-from, |
| .dropdown-leave-to { |
| opacity: 0; |
| transform: scale(0.95) translateY(-4px); |
| } |
| |
| .line-clamp-3 { |
| display: -webkit-box; |
| -webkit-line-clamp: 3; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| } |
| </style> |
| |