SystemUpdatePanel.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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 = withDefaults(defineProps<{ active?: boolean }>(), { active: true })
  14. const emit = defineEmits<{ 'lock-change': [locked: boolean] }>()
  15. const status = ref<SystemUpdateStatus>()
  16. const job = ref<UpdateJob>()
  17. const busy = ref(false)
  18. const checking = ref(false)
  19. const remoteChecked = ref(false)
  20. const error = ref('')
  21. const trackingTimedOut = ref(false)
  22. const operationStarting = ref(false)
  23. let pollToken = 0
  24. let lifecycleToken = 0
  25. const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
  26. const jobRunning = computed(() => Boolean(job.value && !terminal.value))
  27. const locked = computed(() => operationStarting.value || (jobRunning.value && !trackingTimedOut.value))
  28. const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !jobRunning.value && !busy.value))
  29. const displayedJob = computed(() => {
  30. const current = status.value?.current, value = job.value
  31. if (value?.state === 'succeeded' && value.target && current && (value.target.webVersion !== current.webVersion || value.target.releaseVersion !== current.releaseVersion)) return undefined
  32. return value
  33. })
  34. function stopPolling() { pollToken += 1 }
  35. function reloadPage() { window.location.reload() }
  36. async function refresh(checkRemote = false): Promise<SystemUpdateStatus | undefined> {
  37. error.value = ''
  38. checking.value = checkRemote
  39. try {
  40. status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
  41. if (checkRemote) remoteChecked.value = true
  42. job.value = status.value.job
  43. return status.value
  44. } catch (cause) {
  45. if (checkRemote) remoteChecked.value = false
  46. error.value = cause instanceof Error ? cause.message : '无法读取系统版本'
  47. } finally {
  48. checking.value = false
  49. }
  50. return undefined
  51. }
  52. async function poll(next: UpdateJob) {
  53. job.value = next
  54. trackingTimedOut.value = false
  55. const token = ++pollToken
  56. for (let attempt = 0; attempt < 300 && token === pollToken; attempt += 1) {
  57. if (['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) break
  58. await new Promise(resolvePromise => window.setTimeout(resolvePromise, 1_000))
  59. try {
  60. job.value = await systemUpdateJob(next.id)
  61. } catch {
  62. // 15300 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
  63. }
  64. }
  65. if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
  66. error.value = '升级仍在后台执行,请稍后重新打开此页面查看'
  67. trackingTimedOut.value = true
  68. }
  69. if (token === pollToken && !trackingTimedOut.value) await refresh(false)
  70. }
  71. async function loadAndResume(token: number) {
  72. await refresh(false)
  73. if (token !== lifecycleToken || !props.active) return
  74. if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
  75. await poll(job.value)
  76. return
  77. }
  78. await refresh(true)
  79. if (token !== lifecycleToken || !props.active) return
  80. if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) await poll(job.value)
  81. }
  82. async function applyUpdate() {
  83. const target = status.value?.latest
  84. if (!target || !window.confirm(`将 Web 升级到 ${target.webVersion}?`)) return
  85. const token = lifecycleToken
  86. operationStarting.value = true
  87. busy.value = true
  88. error.value = ''
  89. try {
  90. const next = await applySystemUpdate(target.releaseVersion)
  91. if (token !== lifecycleToken || !props.active) return
  92. operationStarting.value = false
  93. await poll(next)
  94. } catch (cause) {
  95. if (token === lifecycleToken) error.value = cause instanceof Error ? cause.message : '无法启动系统升级'
  96. } finally {
  97. if (token === lifecycleToken) {
  98. operationStarting.value = false
  99. busy.value = false
  100. }
  101. }
  102. }
  103. async function rollback() {
  104. if (!window.confirm('回滚到上一个 Web 版本?')) return
  105. const token = lifecycleToken
  106. operationStarting.value = true
  107. busy.value = true
  108. error.value = ''
  109. try {
  110. const next = await rollbackSystemUpdate()
  111. if (token !== lifecycleToken || !props.active) return
  112. operationStarting.value = false
  113. await poll(next)
  114. } catch (cause) {
  115. if (token === lifecycleToken) error.value = cause instanceof Error ? cause.message : '无法启动系统回滚'
  116. } finally {
  117. if (token === lifecycleToken) {
  118. operationStarting.value = false
  119. busy.value = false
  120. }
  121. }
  122. }
  123. watch(locked, value => emit('lock-change', value), { immediate: true })
  124. watch(() => props.active, active => {
  125. const token = ++lifecycleToken
  126. stopPolling()
  127. error.value = ''
  128. trackingTimedOut.value = false
  129. operationStarting.value = false
  130. if (!active) {
  131. emit('lock-change', false)
  132. return
  133. }
  134. job.value = undefined
  135. remoteChecked.value = false
  136. busy.value = true
  137. void loadAndResume(token)
  138. .finally(() => { if (token === lifecycleToken) busy.value = false })
  139. }, { immediate: true })
  140. onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; stopPolling(); emit('lock-change', false) })
  141. </script>
  142. <template>
  143. <section class="system-update-panel" aria-label="更新与回滚">
  144. <p class="system-update-intro">查看 Web 版本、检查更新或回滚到上一个版本。更新完成后刷新页面即可使用新版本。</p>
  145. <p v-if="error" class="system-update-error" role="alert"><AppIcon name="alert" :size="16" />{{ error }}</p>
  146. <section v-if="status" class="version-grid" aria-label="版本信息">
  147. <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
  148. <article><small>最新 Web</small><strong>{{ error ? '检查失败' : !remoteChecked ? '待检查' : status.latest?.webVersion || status.current.webVersion }}</strong></article>
  149. </section>
  150. <p v-if="status?.releaseSource" class="mode-note">发布源:{{ status.releaseSource }} <a v-if="status.releasePageUrl" :href="status.releasePageUrl" target="_blank" rel="noopener noreferrer">查看发布说明</a></p>
  151. <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
  152. <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
  153. <section v-if="displayedJob" class="update-progress" :class="`update-progress--${displayedJob.state}`" aria-live="polite">
  154. <span class="progress-icon"><AppIcon :name="displayedJob.state === 'succeeded' || displayedJob.state === 'rolled_back' ? 'check' : displayedJob.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
  155. <span><strong>{{ displayedJob.message }}</strong><small v-if="displayedJob.error">{{ displayedJob.error }}</small></span>
  156. </section>
  157. <footer>
  158. <button v-if="displayedJob?.state === 'succeeded' || displayedJob?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
  159. <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
  160. <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
  161. <button class="solid-button" type="button" :disabled="!canApply || !!error || !remoteChecked" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : error ? '暂无法检查' : !remoteChecked ? '待检查更新' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
  162. </footer>
  163. </section>
  164. </template>
  165. <style scoped>
  166. .system-update-panel { display: grid; gap: 18px; }
  167. .system-update-intro { margin: 0; max-width: 620px; color: var(--text-secondary); font-size: 14px; line-height: 1.65; }
  168. .system-update-error,.version-warning { display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 12px 14px; border-radius: 10px; font-size: 13px; line-height: 1.55; }
  169. .system-update-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
  170. .version-warning { background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
  171. .version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-block: 1px solid var(--line); }
  172. .version-grid article { display: grid; gap: 7px; padding: 18px 20px; border-bottom: 1px solid var(--line); }
  173. .version-grid article:nth-child(odd) { border-right: 1px solid var(--line); }
  174. .version-grid article:nth-last-child(-n+2) { border-bottom: 0; }
  175. .version-grid small { color: var(--text-muted); font-size: 12px; }
  176. .version-grid strong { font-size: 20px; letter-spacing: -.02em; }
  177. .mode-note { margin: -4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
  178. .update-progress { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
  179. .progress-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }
  180. .update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }
  181. .update-progress strong { font-size: 14px; }
  182. .update-progress small { color: var(--danger); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
  183. .update-progress:not(.update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon { animation: update-spin 1s linear infinite; }
  184. .update-progress--succeeded .progress-icon,.update-progress--rolled_back .progress-icon { color: var(--success); }
  185. .update-progress--failed .progress-icon { color: var(--danger); }
  186. footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 6px; padding-top: 18px; border-top: 1px solid var(--line); }
  187. button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-radius: 9px; cursor: pointer; font: 600 13px var(--font-ui); }
  188. .quiet-button { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-secondary); }
  189. .solid-button { background: var(--accent); color: var(--text-on-solid); }
  190. button:disabled { cursor: not-allowed; opacity: .5; }
  191. .danger { margin-right: auto; color: var(--danger); }
  192. @keyframes update-spin { to { transform: rotate(360deg); } }
  193. @media (max-width: 700px) {
  194. .version-grid { grid-template-columns: 1fr; }
  195. .version-grid article,.version-grid article:nth-child(odd),.version-grid article:nth-last-child(-n+2) { border-right: 0; border-bottom: 1px solid var(--line); }
  196. .version-grid article:last-child { border-bottom: 0; }
  197. footer .solid-button { flex: 1 1 100%; }
  198. }
  199. </style>