feat(security): 一键更新 UI + 私有/公有化条件渲染
- 安全中心按部署模式条件显示:PRIVATE 显示更新卡片,PUBLIC 显示迁移卡片 - 新增 api/system.ts:getDeploymentStatus + streamSystemUpdate(流式日志) - 更新进度用暗色终端风格日志框展示,自动滚动到底部 - RESTART_SELF 事件后轮询服务恢复,最长等待 90s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
66129cb89d
当前提交
ef0876fc5d
@ -0,0 +1,43 @@
|
|||||||
|
const BASE = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
||||||
|
|
||||||
|
export interface DeploymentStatus {
|
||||||
|
mode: 'PUBLIC' | 'PRIVATE'
|
||||||
|
tenantRegisterEnabled: boolean
|
||||||
|
services: Record<string, { enabled: boolean; baseUrl: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeploymentStatus(): Promise<DeploymentStatus> {
|
||||||
|
const res = await fetch(`${BASE}/private/deployment/status`)
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data as DeploymentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamSystemUpdate(
|
||||||
|
onLine: (line: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem('token') ?? ''
|
||||||
|
const res = await fetch(`${BASE}/system/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const reader = res.body!.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buf.split('\n')
|
||||||
|
buf = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
onLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf) onLine(buf)
|
||||||
|
}
|
||||||
@ -38,7 +38,8 @@
|
|||||||
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
|
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card style="margin-bottom: 16px">
|
<!-- 私有化迁移:仅公有化平台显示 -->
|
||||||
|
<el-card v-if="deploymentMode === 'PUBLIC'" style="margin-bottom: 16px">
|
||||||
<template #header>私有化部署迁移</template>
|
<template #header>私有化部署迁移</template>
|
||||||
<p style="color: #606266; margin: 0 0 16px;">
|
<p style="color: #606266; margin: 0 0 16px;">
|
||||||
将当前账号及应用数据迁移至私有化部署环境。迁移密钥仅在生成时显示一次,请及时保存。
|
将当前账号及应用数据迁移至私有化部署环境。迁移密钥仅在生成时显示一次,请及时保存。
|
||||||
@ -46,6 +47,20 @@
|
|||||||
<el-button type="warning" plain @click="openMigrateDialog">生成迁移密钥</el-button>
|
<el-button type="warning" plain @click="openMigrateDialog">生成迁移密钥</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 一键更新:仅私有化平台显示 -->
|
||||||
|
<el-card v-if="deploymentMode === 'PRIVATE'" style="margin-bottom: 16px">
|
||||||
|
<template #header>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<span>系统更新</span>
|
||||||
|
<el-tag type="info" size="small">私有化部署</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p style="color: #606266; margin: 0 0 16px;">
|
||||||
|
拉取最新镜像并逐一重启各服务容器,全程无需手动操作。tenant-service 重启时页面连接会短暂中断,更新后自动恢复。
|
||||||
|
</p>
|
||||||
|
<el-button type="primary" @click="showUpdateDialog = true">立即更新</el-button>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>应用密钥管理</template>
|
<template #header>应用密钥管理</template>
|
||||||
<el-table :data="apps" v-loading="loading" border stripe>
|
<el-table :data="apps" v-loading="loading" border stripe>
|
||||||
@ -64,6 +79,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 应用密钥操作 dialog -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showDialog"
|
v-model="showDialog"
|
||||||
:title="dialogMode === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'"
|
:title="dialogMode === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'"
|
||||||
@ -90,7 +106,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Migration: step 1 — request email code -->
|
<!-- Migration dialogs -->
|
||||||
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
|
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
|
||||||
<div v-if="!migrateCodeSent">
|
<div v-if="!migrateCodeSent">
|
||||||
<p class="dialog-text">点击发送验证码,系统将向您的注册邮箱发送一次性验证码,验证通过后生成迁移密钥。</p>
|
<p class="dialog-text">点击发送验证码,系统将向您的注册邮箱发送一次性验证码,验证通过后生成迁移密钥。</p>
|
||||||
@ -108,7 +124,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Migration: step 2 — display one-time key -->
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showMigrateKey"
|
v-model="showMigrateKey"
|
||||||
title="迁移密钥"
|
title="迁移密钥"
|
||||||
@ -130,6 +145,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- AppSecret result dialog -->
|
||||||
<el-dialog v-model="showResult" title="AppSecret" width="420px">
|
<el-dialog v-model="showResult" title="AppSecret" width="420px">
|
||||||
<el-alert type="success" :closable="false" show-icon>
|
<el-alert type="success" :closable="false" show-icon>
|
||||||
<template #title>操作已完成</template>
|
<template #title>操作已完成</template>
|
||||||
@ -141,21 +157,71 @@
|
|||||||
<el-button type="primary" @click="showResult = false">知道了</el-button>
|
<el-button type="primary" @click="showResult = false">知道了</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 一键更新 dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showUpdateDialog"
|
||||||
|
title="一键更新"
|
||||||
|
width="600px"
|
||||||
|
:close-on-click-modal="!updating"
|
||||||
|
:close-on-press-escape="!updating"
|
||||||
|
@closed="resetUpdateDialog"
|
||||||
|
>
|
||||||
|
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
|
||||||
|
<p>将拉取最新镜像并重启所有运行中的服务容器。</p>
|
||||||
|
<el-alert type="warning" :closable="false" show-icon style="margin-top:12px">
|
||||||
|
<template #title>tenant-service 重启时页面连接会短暂中断(约 10–30 秒)</template>
|
||||||
|
<template #default>更新完成后请刷新页面。</template>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="updating || updateLog.length > 0" class="update-log-wrap">
|
||||||
|
<pre ref="logEl" class="update-log">{{ updateLog.join('\n') }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selfRestarting" class="reconnect-tip">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>正在等待服务重启...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="updateDone" style="margin-top:12px">
|
||||||
|
<el-alert type="success" :closable="false" show-icon title="更新完成!" />
|
||||||
|
</div>
|
||||||
|
<div v-if="updateError" style="margin-top:12px">
|
||||||
|
<el-alert type="error" :closable="false" show-icon :title="updateError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button v-if="!updating && !selfRestarting" @click="showUpdateDialog = false">
|
||||||
|
{{ updateDone || updateError ? '关闭' : '取消' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!updating && !updateDone && !selfRestarting"
|
||||||
|
type="primary"
|
||||||
|
@click="startUpdate"
|
||||||
|
>
|
||||||
|
开始更新
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
import { accountApi } from '@/api/account'
|
import { accountApi } from '@/api/account'
|
||||||
import { appApi, type App } from '@/api/app'
|
import { appApi, type App } from '@/api/app'
|
||||||
import { migrateApi } from '@/api/migrate'
|
import { migrateApi } from '@/api/migrate'
|
||||||
|
import { getDeploymentStatus, streamSystemUpdate } from '@/api/system'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const apps = ref<App[]>([])
|
const apps = ref<App[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const subAccountCount = ref(0)
|
const subAccountCount = ref(0)
|
||||||
|
const deploymentMode = ref<'PUBLIC' | 'PRIVATE' | null>(null)
|
||||||
|
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
const showResult = ref(false)
|
const showResult = ref(false)
|
||||||
@ -201,10 +267,7 @@ async function sendVerifyCode() {
|
|||||||
|
|
||||||
async function submitVerify() {
|
async function submitVerify() {
|
||||||
if (!selectedApp.value) return
|
if (!selectedApp.value) return
|
||||||
if (!verifyCode.value.trim()) {
|
if (!verifyCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
|
||||||
ElMessage.warning('请输入验证码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (dialogMode.value === 'REVEAL_SECRET') {
|
if (dialogMode.value === 'REVEAL_SECRET') {
|
||||||
@ -228,7 +291,7 @@ function closeDialog() {
|
|||||||
codeSent.value = false
|
codeSent.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Migration ────────────────────────────────────────────────────────────────
|
// ── Migration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const showMigrateDialog = ref(false)
|
const showMigrateDialog = ref(false)
|
||||||
const showMigrateKey = ref(false)
|
const showMigrateKey = ref(false)
|
||||||
@ -261,10 +324,7 @@ async function sendMigrateCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitMigrateCode() {
|
async function submitMigrateCode() {
|
||||||
if (!migrateCode.value.trim()) {
|
if (!migrateCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
|
||||||
ElMessage.warning('请输入验证码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
migrateSubmitting.value = true
|
migrateSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await migrateApi.generateKey(migrateCode.value.trim())
|
const res = await migrateApi.generateKey(migrateCode.value.trim())
|
||||||
@ -285,11 +345,87 @@ async function copyMigrateKey() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── System Update ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const showUpdateDialog = ref(false)
|
||||||
|
const updating = ref(false)
|
||||||
|
const updateDone = ref(false)
|
||||||
|
const updateError = ref('')
|
||||||
|
const selfRestarting = ref(false)
|
||||||
|
const updateLog = ref<string[]>([])
|
||||||
|
const logEl = ref<HTMLPreElement | null>(null)
|
||||||
|
|
||||||
|
function resetUpdateDialog() {
|
||||||
|
if (updating.value) return
|
||||||
|
updateLog.value = []
|
||||||
|
updateDone.value = false
|
||||||
|
updateError.value = ''
|
||||||
|
selfRestarting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startUpdate() {
|
||||||
|
updating.value = true
|
||||||
|
updateLog.value = []
|
||||||
|
updateDone.value = false
|
||||||
|
updateError.value = ''
|
||||||
|
selfRestarting.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamSystemUpdate((line) => {
|
||||||
|
if (line === 'RESTART_SELF') {
|
||||||
|
selfRestarting.value = true
|
||||||
|
updating.value = false
|
||||||
|
pollForRecovery()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateLog.value.push(line)
|
||||||
|
nextTick(() => {
|
||||||
|
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!selfRestarting.value) {
|
||||||
|
updating.value = false
|
||||||
|
updateDone.value = true
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!selfRestarting.value) {
|
||||||
|
updating.value = false
|
||||||
|
updateError.value = e?.message ?? '更新失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollForRecovery() {
|
||||||
|
const deadline = Date.now() + 90_000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
try {
|
||||||
|
await getDeploymentStatus()
|
||||||
|
selfRestarting.value = false
|
||||||
|
updateDone.value = true
|
||||||
|
updateLog.value.push('>>> tenant-service 已重启,更新完成 ✓')
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// still restarting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selfRestarting.value = false
|
||||||
|
updateError.value = '等待 tenant-service 重启超时,请手动刷新页面'
|
||||||
|
}
|
||||||
|
|
||||||
function fmt(value: string) {
|
function fmt(value: string) {
|
||||||
return value ? new Date(value).toLocaleString('zh-CN') : '-'
|
return value ? new Date(value).toLocaleString('zh-CN') : '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(async () => {
|
||||||
|
loadData()
|
||||||
|
try {
|
||||||
|
const status = await getDeploymentStatus()
|
||||||
|
deploymentMode.value = status.mode
|
||||||
|
} catch {
|
||||||
|
deploymentMode.value = 'PUBLIC'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -327,4 +463,30 @@ onMounted(loadData)
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
.update-log-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1d2129;
|
||||||
|
}
|
||||||
|
.update-log {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.reconnect-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户