Files
OpenList-Desktop/src/components/dashboard/UpdateManagerCard.vue
2025-08-26 10:08:53 +08:00

932 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Card
:title="t('update.title')"
variant="elevated"
hover
class="update-manager-card"
:class="{ standalone: isStandalone }"
>
<div class="update-content">
<div class="version-info">
<div class="current-version">
<h4>{{ t('update.currentVersion') }}</h4>
<span class="version-tag">v{{ currentVersion }}</span>
</div>
<button @click="checkForUpdates" :disabled="checking || downloading || installing" class="check-update-btn">
<RefreshCw :size="16" />
{{ checking ? t('update.checking') : t('update.checkForUpdates') }}
</button>
</div>
<div class="settings-row">
<div class="auto-check-setting">
<label class="checkbox-container">
<input type="checkbox" v-model="autoCheckEnabled" @change="toggleAutoCheck" :disabled="settingsLoading" />
<span class="label-text">{{ t('update.autoCheck') }}</span>
</label>
</div>
</div>
<div v-if="error" class="error-state">
<div class="error-content">
<AlertCircle :size="16" />
<span>{{ error }}</span>
</div>
<button @click="clearError" class="clear-error-btn">×</button>
</div>
<div v-if="!updateCheck?.hasUpdate && lastChecked && !checking && !error" class="no-updates">
<CheckCircle :size="24" class="check-icon" />
<div class="no-updates-text">
<h4>{{ t('update.upToDate') }}</h4>
<p>{{ t('update.lastChecked') }}: {{ formatDate(lastChecked) }}</p>
</div>
</div>
<div v-if="updateCheck?.hasUpdate && !installing" class="update-available">
<div class="update-header">
<Download :size="24" class="update-icon" />
<div class="update-details">
<h4>{{ t('update.updateAvailable') }}</h4>
<div class="version-comparison">
<span class="current">v{{ updateCheck.currentVersion }}</span>
<ArrowRight :size="16" />
<span class="new">{{ updateCheck.latestVersion }}</span>
</div>
<div class="release-date">{{ t('update.releaseDate') }}: {{ formatDate(updateCheck.releaseDate) }}</div>
</div>
</div>
<div v-if="updateCheck.releaseNotes" class="release-notes">
<h5>{{ t('update.releaseNotes') }}</h5>
<div class="notes-content" v-html="formatReleaseNotes(updateCheck.releaseNotes)"></div>
</div>
<div v-if="updateCheck.assets.length > 0" class="assets-section">
<h5>{{ t('update.availableInstallers') }}</h5>
<div class="assets-list">
<div
v-for="asset in updateCheck.assets"
:key="asset.name"
class="asset-item"
:class="{ selected: selectedAsset?.name === asset.name }"
@click="selectAsset(asset)"
>
<div class="asset-info">
<div class="asset-name">{{ asset.name }}</div>
<div class="asset-details">
<span class="asset-type">{{ asset.type.toUpperCase() }}</span>
<span class="asset-size">{{ formatBytes(asset.size) }}</span>
</div>
</div>
<div class="asset-platform">{{ asset.platform }}</div>
</div>
</div>
</div>
<div v-if="downloading" class="progress-container">
<div class="progress-info">
<span class="progress-text">{{ t('update.downloading') }}...</span>
<span class="progress-percentage">{{ Math.round(downloadProgress?.percentage || 0) }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${downloadProgress?.percentage || 0}%` }"></div>
</div>
<div class="progress-details">
<span class="speed">{{ formatSpeed(downloadProgress?.speed || 0) }}</span>
<span class="size-info">
{{ formatBytes(downloadProgress?.downloaded || 0) }} / {{ formatBytes(downloadProgress?.total || 0) }}
</span>
</div>
</div>
<div class="update-actions" v-if="!downloading">
<button
@click="downloadAndInstall"
:disabled="!selectedAsset || checking || downloading || installing"
class="install-btn"
>
<Download :size="16" />
{{ t('update.downloadAndInstall') }}
</button>
</div>
</div>
<div v-if="installationStatus" class="status-message" :class="installationStatusType">
<div class="status-content">
<component :is="getStatusIcon()" :size="16" />
<span>{{ installationStatus }}</span>
</div>
</div>
<div v-if="backgroundUpdateAvailable" class="background-update-notification">
<div class="notification-content">
<Info :size="20" class="notification-icon" />
<div class="notification-text">
<span>{{ t('update.backgroundUpdateAvailable') }}</span>
<button @click="showBackgroundUpdate" class="show-update-btn">
{{ t('update.showUpdate') }}
</button>
</div>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useTranslation } from '../../composables/useI18n'
import { useAppStore } from '../../stores/app'
import { TauriAPI } from '../../api/tauri'
import Card from '../ui/Card.vue'
import { formatBytes } from '@/utils/formatters'
import { RefreshCw, Download, ArrowRight, CheckCircle, AlertCircle, Info, CheckCircle2 } from 'lucide-vue-next'
interface Props {
isStandalone?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isStandalone: false
})
const { t } = useTranslation()
const appStore = useAppStore()
const isStandalone = computed(() => props.isStandalone)
const currentVersion = ref('')
const updateCheck = ref<UpdateCheck | null>(null)
const backgroundUpdateCheck = ref<UpdateCheck | null>(null)
const checking = ref(false)
const downloading = ref(false)
const installing = ref(false)
const downloadProgress = ref<DownloadProgress | null>(null)
const lastChecked = ref<string | null>(null)
const error = ref<string | null>(null)
const autoCheckEnabled = ref(true)
const settingsLoading = ref(false)
const installationStatus = ref<string | null>(null)
const installationStatusType = ref<'info' | 'success' | 'error'>('info')
const selectedAsset = ref<UpdateAsset | null>(null)
const backgroundUpdateAvailable = computed(() => backgroundUpdateCheck.value && !updateCheck.value?.hasUpdate)
let backgroundUpdateUnlisten: (() => void) | null = null
let downloadProgressUnlisten: (() => void) | null = null
let installStartedUnlisten: (() => void) | null = null
let installErrorUnlisten: (() => void) | null = null
let appQuitEventUnsubscriber: (() => void) | null = null
const checkForUpdates = async () => {
if (checking.value || downloading.value || installing.value) return
try {
checking.value = true
error.value = null
const result = await TauriAPI.updater.check()
updateCheck.value = result
if (result.hasUpdate && result.assets.length > 0) {
selectedAsset.value = result.assets[0]
}
lastChecked.value = new Date().toISOString()
if (!result.hasUpdate) {
installationStatus.value = t('update.noUpdatesFound')
installationStatusType.value = 'success'
setTimeout(() => {
installationStatus.value = null
}, 3000)
}
} catch (err: any) {
console.error('Failed to check for updates:', err)
error.value = t('update.checkError') + String(err ? `: ${err}` : '')
} finally {
checking.value = false
}
}
const selectAsset = (asset: UpdateAsset) => {
selectedAsset.value = asset
}
const downloadAndInstall = async () => {
if (!selectedAsset.value || downloading.value || installing.value) return
try {
downloading.value = true
installationStatus.value = t('update.startingDownload')
installationStatusType.value = 'info'
const filePath = await TauriAPI.updater.download(selectedAsset.value.url, selectedAsset.value.name)
downloading.value = false
installing.value = true
installationStatus.value = t('update.installingUpdate')
installationStatusType.value = 'info'
await TauriAPI.updater.installAndRestart(filePath)
} catch (err: any) {
console.error('Failed to download/install update:', err)
downloading.value = false
installing.value = false
error.value = err.message || t('update.installError')
installationStatus.value = t('update.installError')
installationStatusType.value = 'error'
}
}
const toggleAutoCheck = async () => {
if (settingsLoading.value) return
try {
settingsLoading.value = true
await TauriAPI.updater.setAutoCheck(autoCheckEnabled.value)
} catch (err: any) {
console.error('Failed to update auto-check setting:', err)
autoCheckEnabled.value = !autoCheckEnabled.value
} finally {
settingsLoading.value = false
}
}
const showBackgroundUpdate = () => {
if (backgroundUpdateCheck.value) {
updateCheck.value = backgroundUpdateCheck.value
backgroundUpdateCheck.value = null
if (updateCheck.value.assets.length > 0) {
selectedAsset.value = updateCheck.value.assets[0]
}
}
}
const clearError = () => {
error.value = null
}
const getStatusIcon = () => {
switch (installationStatusType.value) {
case 'success':
return CheckCircle2
case 'error':
return AlertCircle
default:
return Info
}
}
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
} catch {
return dateString
}
}
const formatReleaseNotes = (notes: string) => {
return notes
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>')
}
const formatSpeed = (bytesPerSecond: number) => {
if (bytesPerSecond === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMounted(async () => {
try {
if (appStore.updateAvailable && appStore.updateCheck) {
updateCheck.value = appStore.updateCheck
if (appStore.updateCheck.assets.length > 0) {
selectedAsset.value = appStore.updateCheck.assets[0]
}
}
appStore.clearUpdateStatus()
currentVersion.value = await TauriAPI.updater.currentVersion()
autoCheckEnabled.value = await TauriAPI.updater.isAutoCheckEnabled()
try {
backgroundUpdateUnlisten = await TauriAPI.updater.onBackgroundUpdate(updateInfo => {
console.log('Background update available:', updateInfo)
backgroundUpdateCheck.value = updateInfo
})
} catch (err) {
console.warn('Background update listener not available:', err)
backgroundUpdateUnlisten = null
}
try {
downloadProgressUnlisten = await TauriAPI.updater.onDownloadProgress(progress => {
downloadProgress.value = progress
})
} catch (err) {
console.warn('Download progress listener not available:', err)
downloadProgressUnlisten = null
}
try {
installStartedUnlisten = await TauriAPI.updater.onInstallStarted(() => {
installing.value = true
installationStatus.value = t('update.installingUpdate')
installationStatusType.value = 'info'
})
} catch (err) {
console.warn('Install started listener not available:', err)
installStartedUnlisten = null
}
try {
installErrorUnlisten = await TauriAPI.updater.onInstallError(errorMsg => {
installing.value = false
error.value = errorMsg
installationStatus.value = t('update.installError')
installationStatusType.value = 'error'
})
} catch (err) {
console.warn('Install error listener not available:', err)
installErrorUnlisten = null
}
try {
appQuitEventUnsubscriber = await TauriAPI.updater.onAppQuit(() => {
installationStatus.value = t('update.quitApp')
installationStatusType.value = 'success'
})
} catch (err) {
console.warn('App restarting listener not available:', err)
appQuitEventUnsubscriber = null
}
if (autoCheckEnabled.value) {
await checkForUpdates()
}
} catch (err) {
console.error('Failed to initialize update manager:', err)
}
})
onUnmounted(() => {
try {
backgroundUpdateUnlisten?.()
} catch (err) {
console.warn('Error unregistering background update listener:', err)
}
try {
downloadProgressUnlisten?.()
} catch (err) {
console.warn('Error unregistering download progress listener:', err)
}
try {
installStartedUnlisten?.()
} catch (err) {
console.warn('Error unregistering install started listener:', err)
}
try {
installErrorUnlisten?.()
} catch (err) {
console.warn('Error unregistering install error listener:', err)
}
try {
appQuitEventUnsubscriber?.()
} catch (err) {
console.warn('Error unregistering app restarting listener:', err)
}
})
</script>
<style scoped>
.update-manager-card {
max-width: 700px;
}
.update-manager-card.standalone {
max-width: 100%;
width: 100%;
box-shadow: none;
border: 1px solid var(--color-border);
}
.update-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.version-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-surface);
border-radius: 8px;
}
.current-version h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.version-tag {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
}
.check-update-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.check-update-btn:hover:not(:disabled) {
background: rgb(39, 221, 145);
}
.check-update-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.settings-row {
padding: 1rem;
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.auto-check-setting {
display: flex;
align-items: center;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
user-select: none;
}
.checkbox-container input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
.checkbox-container .label-text {
font-size: 0.9rem;
color: var(--color-text);
}
.error-state {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.2);
border-radius: 6px;
color: var(--color-danger);
}
:root.dark .error-state,
:root.auto.dark .error-state {
background: rgba(255, 59, 48, 0.05);
border-color: rgba(255, 59, 48, 0.2);
}
.error-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.clear-error-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem;
color: var(--color-danger);
opacity: 0.7;
}
.clear-error-btn:hover {
opacity: 1;
}
.no-updates {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--color-surface);
border-radius: 8px;
}
.check-icon {
color: var(--color-success);
}
.no-updates-text h4 {
margin: 0 0 0.25rem 0;
}
.no-updates-text p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.update-available {
border: 2px solid var(--color-success);
border-radius: 12px;
padding: 1.5rem;
background: rgba(52, 199, 89, 0.1);
}
:root.dark .update-available,
:root.auto.dark .update-available {
background: rgba(52, 199, 89, 0.05);
border-color: var(--color-success);
}
.update-header {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.update-icon {
color: var(--color-success);
flex-shrink: 0;
}
.update-details h4 {
margin: 0 0 0.5rem 0;
color: var(--color-success);
}
.version-comparison {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.version-comparison .current {
color: var(--color-text-secondary);
}
.version-comparison .new {
color: var(--color-success);
font-weight: 600;
}
.release-date {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.release-notes {
margin: 1rem 0;
}
.release-notes h5 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.notes-content {
max-height: 200px;
overflow-y: auto;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.7);
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.4;
}
:root.dark .notes-content,
:root.auto.dark .notes-content {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text-primary);
}
.assets-section {
margin: 1rem 0;
}
.assets-section h5 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: var(--color-success);
}
.assets-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.asset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.8);
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.asset-item:hover {
background: rgba(255, 255, 255, 0.9);
}
.asset-item.selected {
border-color: var(--color-success);
background: rgba(255, 255, 255, 0.95);
}
:root.dark .asset-item,
:root.auto.dark .asset-item {
background: rgba(255, 255, 255, 0.05);
}
:root.dark .asset-item:hover,
:root.auto.dark .asset-item:hover {
background: rgba(255, 255, 255, 0.08);
}
:root.dark .asset-item.selected,
:root.auto.dark .asset-item.selected {
background: rgba(255, 255, 255, 0.1);
border-color: var(--color-success);
}
.asset-info {
flex: 1;
}
.asset-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.asset-details {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.asset-type {
font-weight: 600;
padding: 0.125rem 0.5rem;
background: var(--color-primary);
color: white;
border-radius: 3px;
text-transform: uppercase;
font-size: 0.7rem;
}
.asset-platform {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.progress-container {
margin: 1rem 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.progress-text {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-success);
}
.progress-percentage {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-success);
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.7);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
:root.dark .progress-bar,
:root.auto.dark .progress-bar {
background: rgba(255, 255, 255, 0.1);
}
.progress-fill {
height: 100%;
background: var(--color-success);
}
.progress-details {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.update-actions {
display: flex;
gap: 0.75rem;
}
.install-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--color-success);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.install-btn:hover:not(:disabled) {
background: rgba(52, 199, 89, 0.8);
}
.install-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status-message {
padding: 0.75rem;
border-radius: 6px;
margin-top: 1rem;
}
.status-message.info {
background: rgba(0, 122, 255, 0.1);
border: 1px solid rgba(0, 122, 255, 0.2);
color: var(--color-accent);
}
.status-message.success {
background: rgba(52, 199, 89, 0.1);
border: 1px solid rgba(52, 199, 89, 0.2);
color: var(--color-success);
}
.status-message.error {
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.2);
color: var(--color-danger);
}
:root.dark .status-message.info,
:root.auto.dark .status-message.info {
background: rgba(10, 132, 255, 0.05);
border-color: rgba(10, 132, 255, 0.2);
color: var(--color-accent);
}
:root.dark .status-message.success,
:root.auto.dark .status-message.success {
background: rgba(52, 199, 89, 0.05);
border-color: rgba(52, 199, 89, 0.2);
color: var(--color-success);
}
:root.dark .status-message.error,
:root.auto.dark .status-message.error {
background: rgba(255, 59, 48, 0.05);
border-color: rgba(255, 59, 48, 0.2);
color: var(--color-danger);
}
.status-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.background-update-notification {
padding: 1rem;
background: rgba(0, 122, 255, 0.1);
border: 1px solid rgba(0, 122, 255, 0.2);
border-radius: 8px;
color: var(--color-accent);
}
:root.dark .background-update-notification,
:root.auto.dark .background-update-notification {
background: rgba(10, 132, 255, 0.05);
border-color: rgba(10, 132, 255, 0.2);
}
.notification-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.notification-icon {
color: var(--color-accent);
flex-shrink: 0;
}
.notification-text {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.show-update-btn {
padding: 0.25rem 0.75rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.show-update-btn:hover {
background: var(--color-accent-hover);
}
/* Dark mode specific improvements */
:root.dark .asset-type,
:root.auto.dark .asset-type {
background: var(--color-primary);
color: white;
}
:root.dark .asset-details,
:root.auto.dark .asset-details {
color: var(--color-text-secondary);
}
:root.dark .asset-platform,
:root.auto.dark .asset-platform {
color: var(--color-text-secondary);
}
:root.dark .progress-details,
:root.auto.dark .progress-details {
color: var(--color-text-secondary);
}
:root.dark .update-icon,
:root.auto.dark .update-icon {
color: var(--color-success);
}
:root.dark .install-btn,
:root.auto.dark .install-btn {
background: var(--color-success);
color: white;
}
:root.dark .install-btn:hover:not(:disabled),
:root.auto.dark .install-btn:hover:not(:disabled) {
background: rgba(52, 199, 89, 0.8);
}
</style>