Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
126 lines
8.8 KiB
Vue
126 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||
import { useSystemUpdates } from '@/composables/useSystemUpdates'
|
||
const { state, error, busy, refresh, check, apply } = useSystemUpdates()
|
||
const confirming = ref(false)
|
||
const dialog = ref<HTMLElement | null>(null)
|
||
const updateButton = ref<HTMLButtonElement | null>(null)
|
||
watch(confirming, async open => {
|
||
await nextTick()
|
||
if (open) dialog.value?.querySelector<HTMLButtonElement>('button')?.focus()
|
||
else updateButton.value?.focus()
|
||
})
|
||
const trapFocus = (event: KeyboardEvent) => {
|
||
if (event.key !== 'Tab') return
|
||
const buttons = [...(dialog.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || [])]
|
||
const first = buttons[0], last = buttons[buttons.length - 1]
|
||
if (!first || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
|
||
event.preventDefault()
|
||
;(event.shiftKey ? last : first)?.focus()
|
||
}
|
||
}
|
||
const pending = ref(false)
|
||
const actionError = ref('')
|
||
const canUpdate = computed(() => state.value?.update_available && !busy.value && !state.value.checking && state.value.update?.status !== 'recovery_failed' && !pending.value)
|
||
const resultTitle = computed(() => {
|
||
if (busy.value) return '更新进行中'
|
||
if (state.value?.check_error) return '暂时无法检查新版本'
|
||
if (state.value?.update_available) return '有新版本可以更新'
|
||
return '当前已是最新版本'
|
||
})
|
||
const formatTime = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未检查'
|
||
const short = (value?: string) => value?.slice(0, 8) || '—'
|
||
const run = async (action: () => Promise<void>) => {
|
||
pending.value = true
|
||
actionError.value = ''
|
||
try { await action(); confirming.value = false }
|
||
catch (reason: unknown) {
|
||
actionError.value = (reason as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '请求未完成,请刷新状态后再试。'
|
||
} finally { pending.value = false }
|
||
}
|
||
let disposed = false
|
||
let timer: ReturnType<typeof setTimeout>
|
||
const poll = async () => {
|
||
await refresh()
|
||
if (!disposed) timer = setTimeout(poll, busy.value || state.value?.checking ? 2000 : 60000)
|
||
}
|
||
onMounted(() => { void poll() })
|
||
onUnmounted(() => { disposed = true; clearTimeout(timer) })
|
||
</script>
|
||
<template>
|
||
<DashboardLayout>
|
||
<div class="updates-page">
|
||
<div class="eyebrow">SYSTEM · 系统</div>
|
||
<div class="page-heading"><div><h1>系统更新</h1><p>自动检查 Gitea,准备好后再由你决定更新。</p></div>
|
||
<button class="btn btn-secondary" :disabled="busy || pending || state?.checking" @click="run(check)">
|
||
{{ state?.checking ? '正在检查…' : '检查更新' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!state" role="status" class="muted">正在读取版本信息…</p>
|
||
<p v-else-if="!state.can_manage" class="muted">系统更新仅对部署管理员开放。</p>
|
||
<template v-else>
|
||
<section class="panel update-panel" aria-labelledby="update-title">
|
||
<div class="status-heading"><span class="status-dot" :class="{ available: state.update_available }"></span>
|
||
<h2 id="update-title">{{ resultTitle }}</h2></div>
|
||
<p class="muted">每 {{ state.check_interval_minutes }} 分钟自动检查一次,只检查、不自动安装。</p>
|
||
<div class="versions">
|
||
<div><span class="version-label">当前版本</span><code>{{ short(state.current?.revision) }}</code><p>{{ state.current?.subject }}</p></div>
|
||
<div><span class="version-label">Gitea · {{ state.branch }}</span><code>{{ short(state.latest?.revision) }}</code><p>{{ state.latest?.subject || '等待检查结果' }}</p></div>
|
||
</div>
|
||
<div v-if="busy" class="progress-note" role="status" aria-live="polite"><span class="spinner"></span>{{ state.update?.message }}</div>
|
||
<p v-else-if="state.update?.status === 'succeeded'" class="success" role="status">{{ state.update.message }} · {{ formatTime(state.update.finished_at) }}</p>
|
||
<div v-else-if="state.update?.error" role="alert" class="error-note"><strong>{{ state.update.message }}</strong><p>{{ state.update.error }}</p></div>
|
||
<p v-if="state.check_error" role="alert" class="error-note">{{ state.check_error }}</p>
|
||
<div class="update-actions"><span class="muted">上次检查:{{ formatTime(state.checked_at) }}</span>
|
||
<button ref="updateButton" class="btn btn-primary" :disabled="!canUpdate" @click="confirming = true">{{ busy ? '正在更新…' : '更新到此版本' }}</button>
|
||
</div>
|
||
</section>
|
||
<p class="update-note">更新前自动备份程序和数据库。构建时可正常接待,切换版本时会短暂停止接待;失败后自动恢复原版本。</p>
|
||
<a v-if="state.repository_url" :href="state.repository_url" target="_blank" rel="noopener noreferrer" class="repo-link">在 Gitea 查看源码 ↗</a>
|
||
</template>
|
||
<p v-if="error || actionError" role="alert" class="error-note">{{ actionError || error }}</p>
|
||
<div v-if="confirming" class="confirm-backdrop" @click.self="!pending && (confirming = false)" @keydown.esc="!pending && (confirming = false)">
|
||
<section ref="dialog" role="dialog" @keydown="trapFocus" aria-modal="true" aria-labelledby="confirm-title" class="confirm-panel">
|
||
<div class="eyebrow">确认更新</div><h2 id="confirm-title">更新到 {{ short(state?.latest?.revision) }}?</h2>
|
||
<p>切换版本时会短暂停止接待。请先处理正在进行的会话,更新完成后页面会自动显示结果。</p>
|
||
<div class="confirm-actions"><button class="btn btn-secondary" :disabled="pending" @click="confirming = false">暂不更新</button>
|
||
<button class="btn btn-primary" :disabled="pending" @click="run(apply)">{{ pending ? '正在提交…' : '备份并开始更新' }}</button></div>
|
||
<p v-if="actionError" role="alert" class="error-note">{{ actionError }}</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</DashboardLayout>
|
||
</template>
|
||
<style scoped>
|
||
.updates-page { max-width: 1040px; margin: 0 auto; }
|
||
.page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: center; margin: 22px 0 38px; }
|
||
h1 { font: 600 38px/1.3 var(--font-serif); margin-bottom: 12px; }
|
||
.page-heading p, .update-note { color: var(--muted); line-height: 1.8; }
|
||
.update-panel { padding: 30px; }
|
||
.status-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||
h2 { font-size: 20px; font-weight: 600; }
|
||
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--celadon); }
|
||
.status-dot.available { background: var(--vermilion); }
|
||
.muted { color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||
.versions { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin: 28px 0; padding: 26px 0; border-block: 1px solid var(--line); }
|
||
.version-label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
||
code { font: 24px var(--font-mono); color: var(--ink); }
|
||
.versions p { font-size: 13px; color: var(--muted); margin-top: 8px; overflow-wrap: anywhere; }
|
||
.update-actions, .confirm-actions { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||
.update-note { font-size: 13px; margin: 22px 0 10px; }
|
||
.repo-link { font-size: 13px; color: var(--celadon); }
|
||
.error-note { color: var(--vermilion); background: var(--vermilion-soft); padding: 12px 16px; border-radius: var(--radius-md); margin-block: 16px; font-size: 13px; overflow-wrap: anywhere; }
|
||
.progress-note { display: flex; align-items: center; gap: 12px; color: var(--celadon); margin: 18px 0; font-size: 14px; }
|
||
.success { color: var(--celadon); margin-bottom: 18px; font-size: 13px; }
|
||
.spinner { width: 16px; height: 16px; border: 2px solid var(--celadon-line); border-top-color: var(--celadon); border-radius: 50%; animation: spin 1s linear infinite; }
|
||
.confirm-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: var(--scrim); display: grid; place-items: center; padding: 24px; }
|
||
.confirm-panel { max-width: 480px; background: var(--surface); padding: 32px; border: 1px solid var(--line); border-radius: var(--radius-card-lg); box-shadow: var(--shadow-lg); }
|
||
.confirm-panel h2 { margin: 20px 0 16px; }
|
||
.confirm-panel p { color: var(--muted); line-height: 1.8; font-size: 14px; }
|
||
.confirm-actions { justify-content: flex-end; margin-top: 28px; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@media (max-width: 640px) { .page-heading { align-items: flex-start; flex-direction: column; gap: 18px; } h1 { font-size: 30px; } .update-panel { padding: 22px; } .versions { grid-template-columns: 1fr; gap: 24px; } .update-actions { align-items: flex-start; flex-direction: column; } }
|
||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||
</style>
|