mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 09:27:46 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
598 lines
21 KiB
Vue
598 lines
21 KiB
Vue
<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="$legacyT('版本信息')"
|
|
>
|
|
<Info
|
|
v-if="!isReconnecting"
|
|
class="h-4 w-4"
|
|
:class="loading ? 'animate-pulse' : ''"
|
|
/>
|
|
<RefreshCw
|
|
v-else
|
|
class="h-4 w-4 animate-spin"
|
|
/>
|
|
</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">
|
|
{{ $legacyT('版本信息') }}
|
|
</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">
|
|
{{ $legacyT('当前版本') }}
|
|
</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">
|
|
{{ $legacyT('最新版本') }}
|
|
</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"
|
|
>
|
|
{{ $legacyT('检查更新失败:') }}{{ $legacyT(status.error) }}
|
|
</p>
|
|
|
|
<p
|
|
v-if="status?.has_update && !canApplyUpdate"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
{{ updateBlockerText }}
|
|
</p>
|
|
|
|
<!-- Reconnecting / busy banner -->
|
|
<div
|
|
v-if="isReconnecting"
|
|
class="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-primary"
|
|
>
|
|
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
|
|
<span class="text-xs font-medium">{{ $legacyT('服务重启中,请稍候...') }}</span>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="isDownloadingUpdate"
|
|
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2"
|
|
>
|
|
<div class="flex items-center justify-between gap-3 text-xs text-primary">
|
|
<span class="truncate">{{ downloadProgressText }}</span>
|
|
<span
|
|
v-if="downloadProgressPercent !== null"
|
|
class="shrink-0 font-mono"
|
|
>
|
|
{{ downloadProgressPercent }}%
|
|
</span>
|
|
</div>
|
|
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/15">
|
|
<div
|
|
class="h-full rounded-full bg-primary transition-all duration-300"
|
|
:style="{ width: progressBarWidth }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="flex items-center gap-2"
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="flex-1"
|
|
:disabled="isBusy || loading"
|
|
@click="handleRefresh"
|
|
>
|
|
<RefreshCw
|
|
class="mr-2 h-3.5 w-3.5"
|
|
:class="loading ? 'animate-spin' : ''"
|
|
/>
|
|
{{ $legacyT('重新检查') }}
|
|
</Button>
|
|
<Button
|
|
v-if="rollbackAvailable"
|
|
variant="outline"
|
|
size="sm"
|
|
class="flex-1"
|
|
:disabled="isBusy"
|
|
@click="handleRollback"
|
|
>
|
|
{{ rollingBack ? '回滚中...' : '回滚' }}
|
|
</Button>
|
|
<Button
|
|
v-if="status?.has_update && status.release_url && !rollbackAvailable"
|
|
size="sm"
|
|
class="flex-1"
|
|
@click="handleOpenRelease"
|
|
>
|
|
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
|
{{ releaseButtonLabel }}
|
|
</Button>
|
|
<Button
|
|
v-if="status?.has_update && canApplyUpdate"
|
|
size="sm"
|
|
class="flex-1"
|
|
:disabled="isBusy"
|
|
@click="handleApplyUpdate"
|
|
>
|
|
<RefreshCw
|
|
class="mr-2 h-3.5 w-3.5"
|
|
:class="updating ? 'animate-spin' : ''"
|
|
/>
|
|
{{ actionButtonLabel }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Releases List -->
|
|
<div v-if="!isReconnecting">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
|
|
@click="toggleReleases"
|
|
>
|
|
<ChevronRight
|
|
class="h-3 w-3 transition-transform"
|
|
:class="showReleases ? 'rotate-90' : ''"
|
|
/>
|
|
{{ $legacyT('历史版本') }}
|
|
<span
|
|
v-if="releases.length > 0"
|
|
class="text-[10px] text-muted-foreground/60"
|
|
>({{ releases.length }})</span>
|
|
<RefreshCw
|
|
v-if="loadingReleases"
|
|
class="ml-auto h-3 w-3 animate-spin text-muted-foreground"
|
|
/>
|
|
</button>
|
|
<div
|
|
v-if="showReleases"
|
|
class="mt-2 max-h-48 space-y-1 overflow-y-auto"
|
|
>
|
|
<p
|
|
v-if="releasesError"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
{{ releasesError }}
|
|
</p>
|
|
<button
|
|
v-for="release in releases"
|
|
:key="release.version"
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md border border-border/40 px-2.5 py-1.5 text-left text-xs transition hover:border-primary/30 hover:bg-primary/5"
|
|
:class="release.is_current ? 'bg-primary/5 border-primary/20' : 'bg-muted/10'"
|
|
@click="openReleaseDetails(release)"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="break-all font-mono font-medium text-foreground">
|
|
{{ formatDisplayVersion(release.version) }}
|
|
</span>
|
|
<span
|
|
v-if="release.is_current"
|
|
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
|
>{{ $legacyT('当前') }}</span>
|
|
<span
|
|
v-else-if="release.is_newer"
|
|
class="shrink-0 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
|
|
>{{ $legacyT('新') }}</span>
|
|
<span
|
|
v-if="release.is_newer && release.updatable === false"
|
|
class="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
|
>{{ $legacyT('不可在线更新') }}</span>
|
|
</div>
|
|
<div
|
|
v-if="release.published_at"
|
|
class="mt-0.5 text-[10px] text-muted-foreground"
|
|
>
|
|
{{ formatDate(release.published_at) }}
|
|
</div>
|
|
<div
|
|
v-if="release.update_blocker"
|
|
class="mt-0.5 text-[10px] text-muted-foreground"
|
|
>
|
|
{{ release.update_blocker }}
|
|
</div>
|
|
</div>
|
|
<span class="ml-2 shrink-0 text-[10px] font-medium text-muted-foreground">
|
|
{{ $legacyT('详情') }}
|
|
</span>
|
|
</button>
|
|
<p
|
|
v-if="!loadingReleases && releases.length === 0 && !releasesError"
|
|
class="py-2 text-center text-xs text-muted-foreground"
|
|
>
|
|
{{ $legacyT('暂无版本信息') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Dialog
|
|
v-model="showReleaseDetails"
|
|
size="xl"
|
|
:title="selectedReleaseTitle"
|
|
:description="selectedReleaseDescription"
|
|
>
|
|
<div
|
|
v-if="selectedRelease"
|
|
class="space-y-4"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="rounded-full border border-border/60 bg-muted/20 px-2 py-0.5 font-mono text-[11px] text-foreground">
|
|
{{ formatDisplayVersion(selectedRelease.version) }}
|
|
</span>
|
|
<span
|
|
v-if="selectedRelease.is_current"
|
|
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
|
>
|
|
{{ $legacyT('当前运行版本') }}
|
|
</span>
|
|
<span
|
|
v-else-if="selectedRelease.is_newer"
|
|
class="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
|
|
>
|
|
{{ $legacyT('可升级版本') }}
|
|
</span>
|
|
<span
|
|
v-else
|
|
class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
>
|
|
{{ $legacyT('历史版本') }}
|
|
</span>
|
|
</div>
|
|
|
|
<p
|
|
v-if="selectedReleaseHelpText"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
{{ selectedReleaseHelpText }}
|
|
</p>
|
|
|
|
<!-- eslint-disable vue/no-v-html -->
|
|
<div
|
|
v-if="selectedReleaseDisplayNotes"
|
|
class="max-h-[26rem] overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
|
v-html="selectedReleaseNotesHtml"
|
|
/>
|
|
<!-- eslint-enable vue/no-v-html -->
|
|
<p
|
|
v-else
|
|
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
|
|
>
|
|
{{ $legacyT('这个版本没有附带更新说明。') }}
|
|
</p>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button
|
|
variant="outline"
|
|
@click="showReleaseDetails = false"
|
|
>
|
|
{{ $legacyT('关闭') }}
|
|
</Button>
|
|
<Button
|
|
v-if="selectedRelease?.release_url"
|
|
variant="outline"
|
|
@click="handleOpenSelectedReleasePage"
|
|
>
|
|
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
|
{{ $legacyT('查看标签页') }}
|
|
</Button>
|
|
<Button
|
|
v-if="canUseSelectedRelease"
|
|
:disabled="isBusy"
|
|
@click="handleUseSelectedRelease"
|
|
>
|
|
<RefreshCw
|
|
class="mr-2 h-3.5 w-3.5"
|
|
:class="isBusy ? 'animate-spin' : ''"
|
|
/>
|
|
{{ selectedReleaseActionLabel }}
|
|
</Button>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import type { CheckUpdateResponse, ReleaseEntry } from '@/api/admin'
|
|
import { adminApi } from '@/api/admin'
|
|
import { Button, Dialog, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
|
|
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
|
import { formatDisplayVersion } from '@/utils/version'
|
|
import { describeUpdateStatus } from '@/utils/updateStatus'
|
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
|
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
|
|
import { useI18n } from '@/i18n'
|
|
import { marked } from 'marked'
|
|
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
|
|
|
const props = defineProps<{
|
|
status: CheckUpdateResponse | null
|
|
loading?: boolean
|
|
updating?: boolean
|
|
updatePhase?: 'download' | 'restart' | 'reconnecting'
|
|
updateSupported?: boolean
|
|
rollbackAvailable?: boolean
|
|
rollingBack?: boolean
|
|
downloadProgressText?: string | null
|
|
downloadProgressPercent?: number | null
|
|
}>()
|
|
const emit = defineEmits<{
|
|
refresh: []
|
|
openRelease: []
|
|
applyUpdate: []
|
|
previewRelease: [release: ReleaseEntry]
|
|
rollback: []
|
|
}>()
|
|
const { legacyT, locale } = useI18n()
|
|
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
|
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
|
|
|
const isOpen = ref(false)
|
|
const showReleases = ref(false)
|
|
const showReleaseDetails = ref(false)
|
|
const loadingReleases = ref(false)
|
|
const releases = ref<ReleaseEntry[]>([])
|
|
const releasesError = ref<string | null>(null)
|
|
const selectedRelease = ref<ReleaseEntry | null>(null)
|
|
let releasesFetched = false
|
|
|
|
const loading = computed(() => props.loading ?? false)
|
|
const updating = computed(() => props.updating ?? false)
|
|
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
|
const updateSupported = computed(() => props.updateSupported ?? true)
|
|
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
|
const rollingBack = computed(() => props.rollingBack ?? false)
|
|
const isReconnecting = computed(() => updatePhase.value === 'reconnecting')
|
|
const isDownloadingUpdate = computed(() => updating.value && updatePhase.value === 'download')
|
|
const isBusy = computed(() => updating.value || rollingBack.value || isReconnecting.value)
|
|
const canApplyUpdate = computed(() => updateSupported.value && props.status?.updatable !== false)
|
|
const downloadProgressText = computed(() => legacyT(props.downloadProgressText || '正在下载更新包...'))
|
|
const downloadProgressPercent = computed(() => {
|
|
const value = props.downloadProgressPercent
|
|
return typeof value === 'number' && Number.isFinite(value)
|
|
? Math.max(0, Math.min(100, Math.round(value)))
|
|
: null
|
|
})
|
|
const progressBarWidth = computed(() => {
|
|
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
|
|
})
|
|
const updateBlockerText = computed(() => {
|
|
if (!updateSupported.value) {
|
|
return legacyT(props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT)
|
|
}
|
|
return legacyT(props.status?.update_blocker || '当前版本暂不支持在线更新')
|
|
})
|
|
const releaseButtonLabel = computed(() => legacyT(updateSupported.value ? '查看更新' : '查看发布'))
|
|
const buttonClass = computed(() => {
|
|
const classes = []
|
|
|
|
if (isReconnecting.value) {
|
|
classes.push('bg-primary/10 text-primary animate-pulse')
|
|
return 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(() => {
|
|
if (isReconnecting.value) return legacyT('重启中')
|
|
if (rollingBack.value) return legacyT('回滚中')
|
|
if (updating.value) return legacyT('更新中')
|
|
return legacyT(describeUpdateStatus(props.status))
|
|
})
|
|
const currentVersionLabel = computed(() => {
|
|
return props.status?.current_version
|
|
? formatDisplayVersion(props.status.current_version)
|
|
: legacyT('加载中...')
|
|
})
|
|
const latestVersionLabel = computed(() => {
|
|
return props.status?.latest_version
|
|
? formatDisplayVersion(props.status.latest_version)
|
|
: ''
|
|
})
|
|
const statusPillClass = computed(() => {
|
|
if (isReconnecting.value || updating.value || rollingBack.value) {
|
|
return 'border-primary/20 bg-primary/10 text-primary'
|
|
}
|
|
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 (isReconnecting.value) return legacyT('服务重启中...')
|
|
if (!props.status) return legacyT('版本信息')
|
|
return `${legacyT('版本信息:')}${statusLabel.value}`
|
|
})
|
|
const actionButtonLabel = computed(() => {
|
|
if (updating.value) {
|
|
return legacyT(updatePhase.value === 'restart' ? '重启中...' : '下载中...')
|
|
}
|
|
return legacyT(updatePhase.value === 'restart' ? '立即重启' : '立即更新')
|
|
})
|
|
const selectedReleaseTitle = computed(() => {
|
|
return selectedRelease.value
|
|
? `${legacyT('版本详情')} · ${formatDisplayVersion(selectedRelease.value.version)}`
|
|
: legacyT('版本详情')
|
|
})
|
|
const selectedReleaseDescription = computed(() => {
|
|
return selectedRelease.value?.published_at
|
|
? `${legacyT('发布于')} ${formatDate(selectedRelease.value.published_at)}`
|
|
: legacyT('查看该版本的发布说明')
|
|
})
|
|
const canUseSelectedRelease = computed(() => {
|
|
return !!selectedRelease.value &&
|
|
!selectedRelease.value.is_current &&
|
|
selectedRelease.value.updatable !== false &&
|
|
updateSupported.value
|
|
})
|
|
const selectedReleaseActionLabel = computed(() => {
|
|
if (!selectedRelease.value) return legacyT('切换到此版本')
|
|
return legacyT(selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本')
|
|
})
|
|
const selectedReleaseHelpText = computed(() => {
|
|
if (!selectedRelease.value) return ''
|
|
if (selectedRelease.value.is_current) return legacyT('当前正在运行这个版本。')
|
|
if (!updateSupported.value) {
|
|
return legacyT(selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT)
|
|
}
|
|
if (selectedRelease.value.update_blocker) return legacyT(selectedRelease.value.update_blocker)
|
|
return legacyT(selectedRelease.value.is_newer
|
|
? '将这个版本作为在线更新目标。'
|
|
: '将切换到这个历史版本。')
|
|
})
|
|
const selectedReleaseDisplayNotes = computed(() => {
|
|
return normalizeReleaseNotesForDisplay(selectedRelease.value?.release_notes)
|
|
})
|
|
const selectedReleaseNotesHtml = computed(() => {
|
|
if (!selectedReleaseDisplayNotes.value) return ''
|
|
try {
|
|
const html = marked.parse(selectedReleaseDisplayNotes.value, {
|
|
async: false,
|
|
breaks: true
|
|
}) as string
|
|
return sanitizeMarkdown(html)
|
|
} catch {
|
|
return selectedReleaseDisplayNotes.value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
}
|
|
})
|
|
|
|
function formatDate(dateStr: string): string {
|
|
try {
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString(locale.value, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
} catch {
|
|
return dateStr
|
|
}
|
|
}
|
|
|
|
async function fetchReleases(force = false) {
|
|
if (releasesFetched && !force) return
|
|
loadingReleases.value = true
|
|
releasesError.value = null
|
|
try {
|
|
const data = await adminApi.getSystemReleases(force)
|
|
releases.value = data.releases
|
|
releasesError.value = data.error ? legacyT(data.error) : null
|
|
releasesFetched = true
|
|
} catch (err) {
|
|
releasesError.value = legacyT(err instanceof Error ? err.message : '获取版本列表失败')
|
|
} finally {
|
|
loadingReleases.value = false
|
|
}
|
|
}
|
|
|
|
function toggleReleases() {
|
|
showReleases.value = !showReleases.value
|
|
if (showReleases.value) {
|
|
fetchReleases()
|
|
}
|
|
}
|
|
|
|
function openReleaseDetails(release: ReleaseEntry) {
|
|
selectedRelease.value = release
|
|
isOpen.value = false
|
|
showReleaseDetails.value = true
|
|
}
|
|
|
|
function handleRefresh() {
|
|
releasesFetched = false
|
|
if (showReleases.value) {
|
|
fetchReleases(true)
|
|
}
|
|
emit('refresh')
|
|
}
|
|
|
|
function handleOpenRelease() {
|
|
isOpen.value = false
|
|
emit('openRelease')
|
|
}
|
|
|
|
function handleOpenSelectedReleasePage() {
|
|
const releaseUrl = safeExternalHttpsUrl(selectedRelease.value?.release_url)
|
|
if (releaseUrl) {
|
|
window.open(releaseUrl, '_blank', 'noopener,noreferrer')
|
|
}
|
|
}
|
|
|
|
function handleUseSelectedRelease() {
|
|
if (!selectedRelease.value || !canUseSelectedRelease.value) return
|
|
showReleaseDetails.value = false
|
|
isOpen.value = false
|
|
emit('previewRelease', selectedRelease.value)
|
|
}
|
|
|
|
function handleApplyUpdate() {
|
|
emit('applyUpdate')
|
|
}
|
|
|
|
function handleRollback() {
|
|
emit('rollback')
|
|
}
|
|
</script>
|