Files
replypier/frontend/src/views/settings/SystemUpdates.vue
T
WangShen007 f3ad539998 feat(updates): check Gitea automatically and deploy on owner confirmation
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.
2026-10-24 07:26:41 +00:00

126 lines
8.8 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.
<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>