SystemUpdateDialog.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import {
  5. applySystemUpdate,
  6. checkSystemUpdate,
  7. rollbackSystemUpdate,
  8. systemUpdateJob,
  9. systemUpdateStatus,
  10. type SystemUpdateStatus,
  11. type UpdateJob,
  12. } from '@/api/systemUpdate'
  13. const props = defineProps<{ open: boolean }>()
  14. const emit = defineEmits<{ close: [] }>()
  15. const status = ref<SystemUpdateStatus>()
  16. const job = ref<UpdateJob>()
  17. const busy = ref(false)
  18. const checking = ref(false)
  19. const error = ref('')
  20. let pollToken = 0
  21. const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
  22. const active = computed(() => job.value && !terminal.value)
  23. const currentPlugin = computed(() => status.value?.installedPluginVersion || '未安装')
  24. const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !active.value && !busy.value))
  25. function stopPolling() { pollToken += 1 }
  26. function reloadPage() { window.location.reload() }
  27. async function refresh(checkRemote = false) {
  28. error.value = ''
  29. checking.value = checkRemote
  30. try {
  31. status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
  32. if (status.value.job) job.value = status.value.job
  33. } catch (cause) {
  34. error.value = cause instanceof Error ? cause.message : '无法读取系统版本'
  35. } finally {
  36. checking.value = false
  37. }
  38. }
  39. async function poll(next: UpdateJob) {
  40. job.value = next
  41. const token = ++pollToken
  42. for (let attempt = 0; attempt < 300 && token === pollToken; attempt += 1) {
  43. if (['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) break
  44. await new Promise(resolvePromise => window.setTimeout(resolvePromise, 1_000))
  45. try {
  46. job.value = await systemUpdateJob(next.id)
  47. } catch {
  48. // 8800 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
  49. }
  50. }
  51. if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
  52. error.value = '升级仍在后台执行,请稍后重新打开此页面查看'
  53. }
  54. if (token === pollToken) await refresh(false)
  55. }
  56. async function applyUpdate() {
  57. const target = status.value?.latest
  58. if (!target || !window.confirm(`升级 Web 到 ${target.webVersion}?插件 ${target.pluginVersion} 将通过 9119 更新。`)) return
  59. busy.value = true
  60. error.value = ''
  61. try {
  62. await poll(await applySystemUpdate(target.releaseVersion))
  63. } catch (cause) {
  64. error.value = cause instanceof Error ? cause.message : '无法启动系统升级'
  65. } finally {
  66. busy.value = false
  67. }
  68. }
  69. async function rollback() {
  70. if (!window.confirm('回滚到上一次 Web 版本?插件继续由 9119 保持最新版本。')) return
  71. busy.value = true
  72. error.value = ''
  73. try {
  74. await poll(await rollbackSystemUpdate())
  75. } catch (cause) {
  76. error.value = cause instanceof Error ? cause.message : '无法启动系统回滚'
  77. } finally {
  78. busy.value = false
  79. }
  80. }
  81. watch(() => props.open, open => {
  82. stopPolling()
  83. error.value = ''
  84. if (open) {
  85. job.value = undefined
  86. busy.value = true
  87. void refresh(false)
  88. .then(() => refresh(true))
  89. .finally(() => { busy.value = false })
  90. }
  91. })
  92. onBeforeUnmount(stopPolling)
  93. </script>
  94. <template>
  95. <Teleport to="body">
  96. <Transition name="system-update-fade">
  97. <div v-if="open" class="system-update-layer" role="presentation" @mousedown.self="!active && emit('close')">
  98. <section class="system-update-dialog" role="dialog" aria-modal="true" aria-labelledby="system-update-title">
  99. <header>
  100. <div><small>SYSTEM RELEASE</small><h2 id="system-update-title">系统更新</h2></div>
  101. <button class="icon-button" type="button" aria-label="关闭系统更新" :disabled="!!active" @click="emit('close')"><AppIcon name="close" /></button>
  102. </header>
  103. <p class="system-update-intro">Web 服务独立升级;Hermes Dashboard 插件由 9119 自动更新到最新兼容版本。</p>
  104. <p v-if="error" class="system-update-error"><AppIcon name="alert" :size="15" />{{ error }}</p>
  105. <section v-if="status" class="version-grid">
  106. <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
  107. <article><small>当前插件</small><strong>{{ currentPlugin }}</strong></article>
  108. <article><small>最新 Web</small><strong>{{ status.latest?.webVersion || status.current.webVersion }}</strong></article>
  109. <article><small>配套插件</small><strong>{{ status.latest?.pluginVersion || status.current.pluginVersion }}</strong></article>
  110. </section>
  111. <p v-if="status && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="14" />当前插件版本落后,登录后会自动通过 9119 更新。</p>
  112. <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
  113. <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
  114. <section v-if="job" class="update-progress" :class="`update-progress--${job.state}`">
  115. <span class="progress-icon"><AppIcon :name="job.state === 'succeeded' || job.state === 'rolled_back' ? 'check' : job.state === 'failed' ? 'alert' : 'refresh'" :size="17" /></span>
  116. <span><strong>{{ job.message }}</strong><small v-if="job.error">{{ job.error }}</small></span>
  117. </section>
  118. <footer>
  119. <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="15" />刷新页面</button>
  120. <button v-else-if="status?.canRollback && !active" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
  121. <button class="quiet-button" type="button" :disabled="checking || !!active" @click="refresh(true)"><AppIcon name="refresh" :size="15" />{{ checking ? '检查中…' : '检查更新' }}</button>
  122. <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ active ? '升级中…' : status?.updateAvailable ? '升级配套版本' : '已是最新版本' }}</button>
  123. </footer>
  124. </section>
  125. </div>
  126. </Transition>
  127. </Teleport>
  128. </template>
  129. <style scoped>
  130. .system-update-layer { position: fixed; z-index: 270; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(6px); }
  131. .system-update-dialog { width: min(520px, 100%); max-height: min(760px, calc(100vh - 36px)); overflow-y: auto; padding: 19px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
  132. header { display: flex; align-items: flex-start; justify-content: space-between; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .08em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
  133. .system-update-intro { margin: 17px 0 14px; color: var(--text-secondary); font-size: 11px; line-height: 1.65; }
  134. .system-update-error, .version-warning { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 13px; padding: 10px; border-radius: 10px; font-size: 10px; line-height: 1.55; }.system-update-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }.version-warning { background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
  135. .version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.version-grid article { display: grid; gap: 5px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }.version-grid small { color: var(--text-muted); font-size: 9px; }.version-grid strong { font-size: 15px; }
  136. .mode-note { margin: 12px 1px 0; color: var(--text-muted); font-size: 9px; line-height: 1.55; }
  137. .update-progress { display: flex; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }.progress-icon { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }.update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }.update-progress strong { font-size: 11px; }.update-progress small { color: var(--danger); font-size: 9px; line-height: 1.5; overflow-wrap: anywhere; }.update-progress:not(.update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon { animation: update-spin 1s linear infinite; }.update-progress--succeeded .progress-icon, .update-progress--rolled_back .progress-icon { color: var(--success); }.update-progress--failed .progress-icon { color: var(--danger); }
  138. footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); } footer button { gap: 6px; }.danger { margin-right: auto; color: var(--danger); }
  139. .system-update-fade-enter-active, .system-update-fade-leave-active { transition: opacity 140ms ease; }.system-update-fade-enter-active .system-update-dialog, .system-update-fade-leave-active .system-update-dialog { transition: transform 170ms var(--ease-out); }.system-update-fade-enter-from, .system-update-fade-leave-to { opacity: 0; }.system-update-fade-enter-from .system-update-dialog, .system-update-fade-leave-to .system-update-dialog { transform: translateY(8px) scale(.985); }
  140. @keyframes update-spin { to { transform: rotate(360deg); } }
  141. @media (max-width: 540px) { .system-update-layer { place-items: end center; padding: 0; }.system-update-dialog { width: 100%; max-height: calc(100vh - 12px); border-radius: 19px 19px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); }.version-grid { grid-template-columns: 1fr 1fr; } footer .solid-button { flex: 1 1 100%; } }
  142. </style>