DesktopUpdateEntry.vue 4.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. <script setup lang="ts">
  2. import { computed } from 'vue'
  3. import type { DesktopUpdateState } from '@shared/desktopUpdate'
  4. import AppIcon from '@/components/common/AppIcon.vue'
  5. const props = defineProps<{ state?: DesktopUpdateState; pending?: boolean; error?: string }>()
  6. const emit = defineEmits<{ activate: [] }>()
  7. const busy = computed(() => Boolean(props.pending || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(props.state?.phase ?? '')))
  8. const progress = computed(() => props.state?.phase === 'downloading' && props.state.total > 0
  9. ? Math.max(0, Math.min(100, Math.floor(props.state.received / props.state.total * 100))) : undefined)
  10. const label = computed(() => {
  11. const state = props.state
  12. if (state?.phase === 'ready') return state.installMode === 'restart' ? '重新启动' : '打开安装包'
  13. if (state?.phase === 'installing') return '正在重启…'
  14. if (state?.phase === 'downloading') return progress.value === undefined ? '正在下载…' : `正在下载 · ${progress.value}%`
  15. if (['verifying', 'preparing'].includes(state?.phase ?? '')) return '正在准备…'
  16. if (state?.phase === 'checking' || props.pending) return '正在检测…'
  17. if (state?.retryable === false) return '更新未完成'
  18. if (state?.available) return '可更新'
  19. if (state?.phase === 'failed' || props.error) return '重试更新'
  20. if (state?.phase === 'checked') return '已是最新版本'
  21. return '检测更新'
  22. })
  23. const detail = computed(() => props.error || props.state?.error || (props.state?.phase === 'ready'
  24. ? props.state.installMode === 'restart' ? '更新已下载,点击重新启动完成安装' : '更新已下载,点击打开安装包'
  25. : busy.value ? label.value : props.state?.available ? `App ${props.state.latestVersion ?? ''} 可更新,点击下载` : props.state?.message))
  26. </script>
  27. <template>
  28. <div class="desktop-update-entry" :class="{ 'desktop-update-entry--ready': state?.phase === 'ready' }" :title="detail">
  29. <button type="button" :title="detail" :aria-label="label" :aria-busy="busy" :disabled="busy || state?.retryable === false" @click="emit('activate')">
  30. <span v-if="busy" class="desktop-update-ring" :class="{ 'desktop-update-ring--spinning': progress === undefined }" role="progressbar" :aria-label="label" :aria-valuemin="0" :aria-valuemax="100" :aria-valuenow="progress">
  31. <svg viewBox="0 0 24 24" aria-hidden="true"><circle class="desktop-update-ring__track" cx="12" cy="12" r="9" /><circle class="desktop-update-ring__value" cx="12" cy="12" r="9" pathLength="100" :stroke-dasharray="`${progress ?? 25} 100`" /></svg>
  32. </span>
  33. <AppIcon v-else :name="state?.phase === 'ready' ? state.installMode === 'restart' ? 'refresh' : 'download' : state?.retryable === false ? 'alert' : state?.available ? 'download' : 'refresh'" :size="20" />
  34. <span class="desktop-update-entry__label" aria-live="polite">{{ label }}</span>
  35. <span v-if="(state?.available || state?.phase === 'ready') && !busy" class="desktop-update-dot" aria-hidden="true" />
  36. </button>
  37. <p v-if="error || state?.error" class="desktop-update-error" role="alert">{{ error || state?.error }}</p>
  38. </div>
  39. </template>
  40. <style scoped>
  41. .desktop-update-entry{position:relative;flex:0 0 auto;width:36px;height:36px;color:var(--accent)}
  42. .desktop-update-entry button{position:relative;display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:0;border:1px solid transparent;border-radius:50%;background:transparent;color:inherit;cursor:pointer}
  43. .desktop-update-entry button:hover{background:var(--surface-hover)}
  44. .desktop-update-entry button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  45. .desktop-update-entry button:disabled{cursor:default}
  46. .desktop-update-entry__label,.desktop-update-error{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
  47. .desktop-update-dot{position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--accent)}
  48. .desktop-update-entry--ready button{background:rgba(var(--accent-rgb), .10)}
  49. .desktop-update-ring{display:inline-flex;flex:0 0 20px;width:20px;height:20px;color:var(--accent)}
  50. .desktop-update-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
  51. .desktop-update-ring circle{fill:none;stroke:currentColor;stroke-width:2.5}
  52. .desktop-update-ring__track{opacity:.18}.desktop-update-ring__value{stroke-linecap:round}
  53. .desktop-update-ring--spinning svg{animation:desktop-update-spin 1s linear infinite}
  54. @keyframes desktop-update-spin{to{transform:rotate(270deg)}}
  55. @media(prefers-reduced-motion:reduce){.desktop-update-ring--spinning svg{animation:none}}
  56. </style>