2026-05-01 21:27:39 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>安全中心</h2>
|
|
|
|
|
|
<p class="subtitle">管理登录安全、应用密钥和子账号风险控制。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-button @click="$router.push('/accounts')">子账号管理</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<el-row :gutter="16" style="margin-bottom: 16px">
|
|
|
|
|
|
<el-col :xs="24" :md="8">
|
|
|
|
|
|
<el-card shadow="hover" class="summary-card">
|
|
|
|
|
|
<el-statistic title="当前账号" :value="auth.user?.nickname || auth.user?.username || '-'" />
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
<el-col :xs="24" :md="8">
|
|
|
|
|
|
<el-card shadow="hover" class="summary-card">
|
|
|
|
|
|
<el-statistic title="应用数量" :value="apps.length" />
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
<el-col :xs="24" :md="8">
|
|
|
|
|
|
<el-card shadow="hover" class="summary-card">
|
|
|
|
|
|
<el-statistic title="子账号数量" :value="subAccountCount" />
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
</el-row>
|
|
|
|
|
|
|
|
|
|
|
|
<el-card style="margin-bottom: 16px">
|
|
|
|
|
|
<template #header>账号保护建议</template>
|
|
|
|
|
|
<el-space wrap>
|
|
|
|
|
|
<el-tag type="success">强密码</el-tag>
|
|
|
|
|
|
<el-tag type="warning">邮箱验证</el-tag>
|
|
|
|
|
|
<el-tag type="info">子账号最小权限</el-tag>
|
|
|
|
|
|
<el-tag type="info">密钥定期轮换</el-tag>
|
|
|
|
|
|
</el-space>
|
|
|
|
|
|
<el-divider />
|
|
|
|
|
|
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<!-- 私有化迁移:仅公有化平台显示 -->
|
|
|
|
|
|
<el-card v-if="deploymentMode === 'PUBLIC'" style="margin-bottom: 16px">
|
2026-05-19 15:12:56 +08:00
|
|
|
|
<template #header>私有化部署迁移</template>
|
|
|
|
|
|
<p style="color: #606266; margin: 0 0 16px;">
|
|
|
|
|
|
将当前账号及应用数据迁移至私有化部署环境。迁移密钥仅在生成时显示一次,请及时保存。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<el-button type="warning" plain @click="openMigrateDialog">生成迁移密钥</el-button>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<!-- 系统运维:仅私有化平台显示 -->
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<el-card v-if="deploymentMode === 'PRIVATE'" style="margin-bottom: 16px">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<span>系统运维</span>
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<el-tag type="info" size="small">私有化部署</el-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<el-descriptions :column="1" border>
|
|
|
|
|
|
<el-descriptions-item label="一键更新">
|
|
|
|
|
|
<span style="color:#606266;font-size:13px;margin-right:16px">拉取最新镜像并重建所有容器,用于升级到新版本。</span>
|
|
|
|
|
|
<el-button type="primary" size="small" @click="openOperationDialog('update')">立即更新</el-button>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="重置容器">
|
|
|
|
|
|
<span style="color:#606266;font-size:13px;margin-right:16px">用当前本地镜像重建所有容器,无需下载新镜像,适合修复异常服务。</span>
|
|
|
|
|
|
<el-button type="warning" size="small" @click="openOperationDialog('reset')">重置容器</el-button>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
2026-05-21 14:47:10 +08:00
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-05-22 18:38:17 +08:00
|
|
|
|
<!-- License 文件解析 -->
|
|
|
|
|
|
<el-card style="margin-bottom: 16px">
|
|
|
|
|
|
<template #header>License 文件解析</template>
|
|
|
|
|
|
<p style="color: #606266; margin: 0 0 16px;">
|
|
|
|
|
|
上传已下载的 License 文件(.xuqmlicense),解析并验证文件内容。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="licenseFileContent"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="4"
|
|
|
|
|
|
placeholder="将 License 文件内容粘贴到此处,或点击上传文件"
|
|
|
|
|
|
style="margin-bottom: 12px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center;">
|
|
|
|
|
|
<el-upload
|
|
|
|
|
|
ref="licenseUploadRef"
|
|
|
|
|
|
action="#"
|
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
|
:on-change="handleLicenseFileChange"
|
|
|
|
|
|
:show-file-list="false"
|
|
|
|
|
|
accept=".xuqmlicense"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-button type="primary" plain>选择 License 文件</el-button>
|
|
|
|
|
|
</el-upload>
|
|
|
|
|
|
<el-button :loading="parsingLicense" @click="parseLicense">解析</el-button>
|
|
|
|
|
|
<el-button v-if="licenseParseResult" @click="clearLicenseParse">清除</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-descriptions v-if="licenseParseResult" :column="isMobile ? 1 : 2" border style="margin-top: 16px">
|
|
|
|
|
|
<el-descriptions-item label="AppKey">{{ licenseParseResult.appKey }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="应用名称">{{ licenseParseResult.appName || '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="Android 包名">{{ licenseParseResult.packageName || '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="iOS BundleId">{{ licenseParseResult.iosBundleId || '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="鸿蒙 BundleName">{{ licenseParseResult.harmonyBundleName || '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="服务地址">{{ licenseParseResult.serverUrl || licenseParseResult.baseUrl || '-' }}</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
|
<el-card>
|
|
|
|
|
|
<template #header>应用密钥管理</template>
|
|
|
|
|
|
<el-table :data="apps" v-loading="loading" border stripe>
|
|
|
|
|
|
<el-table-column prop="name" label="应用名称" min-width="160" />
|
|
|
|
|
|
<el-table-column prop="packageName" label="包名" min-width="180" />
|
|
|
|
|
|
<el-table-column prop="appKey" label="AppKey" min-width="220" show-overflow-tooltip />
|
|
|
|
|
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
|
|
|
|
|
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column label="操作" width="220" fixed="right">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<el-button link type="primary" @click="openSecretDialog(row, 'REVEAL_SECRET')">查看密钥</el-button>
|
|
|
|
|
|
<el-button link type="warning" @click="openSecretDialog(row, 'RESET_SECRET')">重置密钥</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<!-- 应用密钥操作 dialog -->
|
2026-05-01 21:27:39 +08:00
|
|
|
|
<el-dialog
|
|
|
|
|
|
v-model="showDialog"
|
|
|
|
|
|
:title="dialogMode === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'"
|
|
|
|
|
|
width="460px"
|
|
|
|
|
|
@closed="closeDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="!codeSent">
|
|
|
|
|
|
<p class="dialog-text">
|
|
|
|
|
|
{{ dialogMode === 'REVEAL_SECRET'
|
|
|
|
|
|
? '系统将向租户邮箱发送验证码,验证后可查看密钥。'
|
|
|
|
|
|
: '系统将向租户邮箱发送验证码,验证后会立即重置密钥。' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<el-button type="primary" :loading="sendingCode" @click="sendVerifyCode">发送验证码</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else>
|
|
|
|
|
|
<p class="dialog-text">请输入邮箱验证码:</p>
|
|
|
|
|
|
<el-input v-model="verifyCode" maxlength="6" placeholder="6位验证码" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="showDialog = false">取消</el-button>
|
|
|
|
|
|
<el-button v-if="codeSent" type="primary" :loading="submitting" @click="submitVerify">
|
|
|
|
|
|
确认
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<!-- Migration dialogs -->
|
2026-05-19 15:12:56 +08:00
|
|
|
|
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
|
|
|
|
|
|
<div v-if="!migrateCodeSent">
|
|
|
|
|
|
<p class="dialog-text">点击发送验证码,系统将向您的注册邮箱发送一次性验证码,验证通过后生成迁移密钥。</p>
|
|
|
|
|
|
<el-button type="primary" :loading="migrateSendingCode" @click="sendMigrateCode">发送验证码</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else>
|
|
|
|
|
|
<p class="dialog-text">请输入邮箱验证码:</p>
|
|
|
|
|
|
<el-input v-model="migrateCode" maxlength="6" placeholder="6位验证码" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="showMigrateDialog = false">取消</el-button>
|
|
|
|
|
|
<el-button v-if="migrateCodeSent" type="primary" :loading="migrateSubmitting" @click="submitMigrateCode">
|
|
|
|
|
|
生成密钥
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog
|
|
|
|
|
|
v-model="showMigrateKey"
|
|
|
|
|
|
title="迁移密钥"
|
|
|
|
|
|
width="480px"
|
|
|
|
|
|
:close-on-click-modal="false"
|
|
|
|
|
|
:close-on-press-escape="false"
|
|
|
|
|
|
@closed="migrateKeyValue = ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px;">
|
|
|
|
|
|
<template #title>密钥只显示一次,关闭后无法再次查看</template>
|
|
|
|
|
|
<template #default>如果忘记,请重新生成新的迁移密钥。</template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
<div class="migrate-key-box">{{ migrateKeyValue }}</div>
|
|
|
|
|
|
<el-button type="primary" plain size="small" style="margin-top: 12px;" @click="copyMigrateKey">
|
|
|
|
|
|
复制密钥
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button type="danger" @click="showMigrateKey = false">我已保存,关闭</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<!-- AppSecret result dialog -->
|
2026-05-01 21:27:39 +08:00
|
|
|
|
<el-dialog v-model="showResult" title="AppSecret" width="420px">
|
|
|
|
|
|
<el-alert type="success" :closable="false" show-icon>
|
|
|
|
|
|
<template #title>操作已完成</template>
|
|
|
|
|
|
<template #default>
|
|
|
|
|
|
<div class="secret-box">{{ secretResult || '无结果' }}</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button type="primary" @click="showResult = false">知道了</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
2026-05-21 14:47:10 +08:00
|
|
|
|
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<!-- 一键更新 / 重置容器 dialog -->
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<el-dialog
|
|
|
|
|
|
v-model="showUpdateDialog"
|
2026-05-22 15:33:35 +08:00
|
|
|
|
:title="operationType === 'update' ? '一键更新' : '重置容器'"
|
2026-05-21 14:47:10 +08:00
|
|
|
|
width="600px"
|
|
|
|
|
|
:close-on-click-modal="!updating"
|
|
|
|
|
|
:close-on-press-escape="!updating"
|
|
|
|
|
|
@closed="resetUpdateDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<p v-if="operationType === 'update'">将拉取最新镜像并重建所有运行中的服务容器,用于升级到新版本。</p>
|
|
|
|
|
|
<p v-else>将用当前本地镜像重建所有运行中的服务容器,无需下载新镜像,适合修复异常服务。</p>
|
2026-05-22 23:04:36 +08:00
|
|
|
|
<el-descriptions v-if="operationType === 'update'" :column="1" border size="small" style="margin-top:12px;margin-bottom:12px">
|
|
|
|
|
|
<el-descriptions-item label="当前版本">
|
|
|
|
|
|
<el-skeleton v-if="versionLoading" :rows="1" animated style="width:160px" />
|
|
|
|
|
|
<el-tag v-else-if="currentVersion" type="info" size="small">{{ currentVersion }}</el-tag>
|
|
|
|
|
|
<span v-else style="color:#c0c4cc">—</span>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="数据库迁移">自动执行,新版本启动时应用变更</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
|
|
|
|
|
<el-alert type="warning" :closable="false" show-icon>
|
2026-05-21 14:47:10 +08:00
|
|
|
|
<template #title>tenant-service 重启时页面连接会短暂中断(约 10–30 秒)</template>
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<template #default>操作完成后请刷新页面。</template>
|
2026-05-21 14:47:10 +08:00
|
|
|
|
</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">
|
2026-05-22 15:33:35 +08:00
|
|
|
|
<el-alert type="success" :closable="false" show-icon :title="operationType === 'update' ? '更新完成!' : '重置完成!'" />
|
2026-05-21 14:47:10 +08:00
|
|
|
|
</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"
|
2026-05-22 15:33:35 +08:00
|
|
|
|
:type="operationType === 'update' ? 'primary' : 'warning'"
|
|
|
|
|
|
@click="startOperation"
|
2026-05-21 14:47:10 +08:00
|
|
|
|
>
|
2026-05-22 15:33:35 +08:00
|
|
|
|
{{ operationType === 'update' ? '开始更新' : '开始重置' }}
|
2026-05-21 14:47:10 +08:00
|
|
|
|
</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
2026-05-01 21:27:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-22 19:33:38 +08:00
|
|
|
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
2026-05-01 21:27:39 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-05-21 14:47:10 +08:00
|
|
|
|
import { Loading } from '@element-plus/icons-vue'
|
2026-05-01 21:27:39 +08:00
|
|
|
|
import { accountApi } from '@/api/account'
|
|
|
|
|
|
import { appApi, type App } from '@/api/app'
|
2026-05-19 15:12:56 +08:00
|
|
|
|
import { migrateApi } from '@/api/migrate'
|
2026-05-22 23:04:36 +08:00
|
|
|
|
import { getDeploymentStatus, getSystemVersion, streamSystemUpdate, streamSystemReset } from '@/api/system'
|
2026-05-01 21:27:39 +08:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
2026-05-21 16:09:55 +08:00
|
|
|
|
import { formatTime } from '@/utils/date'
|
2026-05-01 21:27:39 +08:00
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
const apps = ref<App[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const subAccountCount = ref(0)
|
2026-05-21 14:47:10 +08:00
|
|
|
|
const deploymentMode = ref<'PUBLIC' | 'PRIVATE' | null>(null)
|
2026-05-01 21:27:39 +08:00
|
|
|
|
|
|
|
|
|
|
const showDialog = ref(false)
|
|
|
|
|
|
const showResult = ref(false)
|
|
|
|
|
|
const sendingCode = ref(false)
|
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
const codeSent = ref(false)
|
|
|
|
|
|
const verifyCode = ref('')
|
|
|
|
|
|
const selectedApp = ref<App | null>(null)
|
|
|
|
|
|
const dialogMode = ref<'REVEAL_SECRET' | 'RESET_SECRET'>('REVEAL_SECRET')
|
|
|
|
|
|
const secretResult = ref('')
|
|
|
|
|
|
|
2026-05-22 18:38:17 +08:00
|
|
|
|
// License file parse
|
|
|
|
|
|
const licenseFileContent = ref('')
|
|
|
|
|
|
const parsingLicense = ref(false)
|
|
|
|
|
|
const licenseParseResult = ref<{
|
|
|
|
|
|
appKey: string
|
|
|
|
|
|
appName: string
|
|
|
|
|
|
packageName: string
|
|
|
|
|
|
iosBundleId: string
|
|
|
|
|
|
harmonyBundleName: string
|
|
|
|
|
|
baseUrl: string
|
|
|
|
|
|
serverUrl: string
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
const licenseUploadRef = ref<any>(null)
|
2026-05-22 19:33:38 +08:00
|
|
|
|
const isMobile = ref(false)
|
|
|
|
|
|
function updateViewport() {
|
|
|
|
|
|
isMobile.value = window.innerWidth < 768
|
|
|
|
|
|
}
|
2026-05-22 18:38:17 +08:00
|
|
|
|
|
|
|
|
|
|
function handleLicenseFileChange(file: any) {
|
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
|
reader.onload = (e) => {
|
|
|
|
|
|
licenseFileContent.value = (e.target?.result as string) || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
reader.readAsText(file.raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function parseLicense() {
|
|
|
|
|
|
if (!licenseFileContent.value.trim()) {
|
|
|
|
|
|
ElMessage.warning('请输入或上传 License 文件内容')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
parsingLicense.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await appApi.parseLicenseFile(licenseFileContent.value.trim())
|
|
|
|
|
|
licenseParseResult.value = res.data.data
|
|
|
|
|
|
ElMessage.success('解析成功')
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
ElMessage.error(e?.response?.data?.message || '解析失败,请检查文件内容')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
parsingLicense.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearLicenseParse() {
|
|
|
|
|
|
licenseFileContent.value = ''
|
|
|
|
|
|
licenseParseResult.value = null
|
|
|
|
|
|
if (licenseUploadRef.value) {
|
|
|
|
|
|
licenseUploadRef.value.clearFiles()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 21:27:39 +08:00
|
|
|
|
async function loadData() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [appsRes, subRes] = await Promise.all([appApi.list(), accountApi.list()])
|
|
|
|
|
|
apps.value = appsRes.data.data
|
|
|
|
|
|
subAccountCount.value = subRes.data.data.length
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openSecretDialog(app: App, mode: 'REVEAL_SECRET' | 'RESET_SECRET') {
|
|
|
|
|
|
selectedApp.value = app
|
|
|
|
|
|
dialogMode.value = mode
|
|
|
|
|
|
showDialog.value = true
|
|
|
|
|
|
codeSent.value = false
|
|
|
|
|
|
verifyCode.value = ''
|
|
|
|
|
|
secretResult.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendVerifyCode() {
|
|
|
|
|
|
if (!selectedApp.value) return
|
|
|
|
|
|
sendingCode.value = true
|
|
|
|
|
|
try {
|
2026-05-09 14:53:43 +08:00
|
|
|
|
await appApi.requestSecretVerify(selectedApp.value.appKey, dialogMode.value)
|
2026-05-01 21:27:39 +08:00
|
|
|
|
codeSent.value = true
|
|
|
|
|
|
ElMessage.success('验证码已发送到邮箱')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sendingCode.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitVerify() {
|
|
|
|
|
|
if (!selectedApp.value) return
|
2026-05-21 14:47:10 +08:00
|
|
|
|
if (!verifyCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
|
2026-05-01 21:27:39 +08:00
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (dialogMode.value === 'REVEAL_SECRET') {
|
2026-05-09 14:53:43 +08:00
|
|
|
|
const res = await appApi.revealSecret(selectedApp.value.appKey, verifyCode.value.trim())
|
2026-05-01 21:27:39 +08:00
|
|
|
|
secretResult.value = res.data.data.appSecret
|
|
|
|
|
|
} else {
|
2026-05-09 14:53:43 +08:00
|
|
|
|
const res = await appApi.resetSecret(selectedApp.value.appKey, verifyCode.value.trim())
|
2026-05-01 21:27:39 +08:00
|
|
|
|
secretResult.value = res.data.data.appSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
showDialog.value = false
|
|
|
|
|
|
showResult.value = true
|
|
|
|
|
|
ElMessage.success(dialogMode.value === 'REVEAL_SECRET' ? '密钥已查看' : '密钥已重置')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeDialog() {
|
|
|
|
|
|
selectedApp.value = null
|
|
|
|
|
|
verifyCode.value = ''
|
|
|
|
|
|
codeSent.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
// ── Migration ─────────────────────────────────────────────────────────────
|
2026-05-19 15:12:56 +08:00
|
|
|
|
|
|
|
|
|
|
const showMigrateDialog = ref(false)
|
|
|
|
|
|
const showMigrateKey = ref(false)
|
|
|
|
|
|
const migrateCodeSent = ref(false)
|
|
|
|
|
|
const migrateSendingCode = ref(false)
|
|
|
|
|
|
const migrateSubmitting = ref(false)
|
|
|
|
|
|
const migrateCode = ref('')
|
|
|
|
|
|
const migrateKeyValue = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
function openMigrateDialog() {
|
|
|
|
|
|
migrateCodeSent.value = false
|
|
|
|
|
|
migrateCode.value = ''
|
|
|
|
|
|
showMigrateDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMigrateDialog() {
|
|
|
|
|
|
migrateCode.value = ''
|
|
|
|
|
|
migrateCodeSent.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendMigrateCode() {
|
|
|
|
|
|
migrateSendingCode.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await migrateApi.requestCode()
|
|
|
|
|
|
migrateCodeSent.value = true
|
|
|
|
|
|
ElMessage.success('验证码已发送到邮箱')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
migrateSendingCode.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitMigrateCode() {
|
2026-05-21 14:47:10 +08:00
|
|
|
|
if (!migrateCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
|
2026-05-19 15:12:56 +08:00
|
|
|
|
migrateSubmitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await migrateApi.generateKey(migrateCode.value.trim())
|
|
|
|
|
|
migrateKeyValue.value = res.data.data.migrationKey
|
|
|
|
|
|
showMigrateDialog.value = false
|
|
|
|
|
|
showMigrateKey.value = true
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
migrateSubmitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyMigrateKey() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(migrateKeyValue.value)
|
|
|
|
|
|
ElMessage.success('已复制到剪贴板')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.warning('复制失败,请手动选择并复制')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
// ── System Update ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const showUpdateDialog = ref(false)
|
2026-05-22 15:33:35 +08:00
|
|
|
|
const operationType = ref<'update' | 'reset'>('update')
|
2026-05-21 14:47:10 +08:00
|
|
|
|
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)
|
2026-05-22 23:04:36 +08:00
|
|
|
|
const currentVersion = ref('')
|
|
|
|
|
|
const versionLoading = ref(false)
|
2026-05-21 14:47:10 +08:00
|
|
|
|
|
2026-05-22 23:04:36 +08:00
|
|
|
|
async function openOperationDialog(type: 'update' | 'reset') {
|
2026-05-22 15:33:35 +08:00
|
|
|
|
operationType.value = type
|
|
|
|
|
|
showUpdateDialog.value = true
|
2026-05-22 23:04:36 +08:00
|
|
|
|
if (type === 'update' && !currentVersion.value) {
|
|
|
|
|
|
versionLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const info = await getSystemVersion()
|
|
|
|
|
|
currentVersion.value = info.currentVersion
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
currentVersion.value = ''
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
versionLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-22 15:33:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
function resetUpdateDialog() {
|
|
|
|
|
|
if (updating.value) return
|
|
|
|
|
|
updateLog.value = []
|
|
|
|
|
|
updateDone.value = false
|
|
|
|
|
|
updateError.value = ''
|
|
|
|
|
|
selfRestarting.value = false
|
2026-05-22 23:04:36 +08:00
|
|
|
|
currentVersion.value = ''
|
2026-05-21 14:47:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 15:33:35 +08:00
|
|
|
|
async function startOperation() {
|
2026-05-21 14:47:10 +08:00
|
|
|
|
updating.value = true
|
|
|
|
|
|
updateLog.value = []
|
|
|
|
|
|
updateDone.value = false
|
|
|
|
|
|
updateError.value = ''
|
|
|
|
|
|
selfRestarting.value = false
|
|
|
|
|
|
|
2026-05-22 15:33:35 +08:00
|
|
|
|
const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset
|
|
|
|
|
|
const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败'
|
|
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
try {
|
2026-05-22 15:33:35 +08:00
|
|
|
|
await streamFn((line) => {
|
2026-05-21 14:47:10 +08:00
|
|
|
|
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
|
2026-05-22 15:33:35 +08:00
|
|
|
|
updateError.value = e?.message ?? failMsg
|
2026-05-21 14:47:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-22 15:33:35 +08:00
|
|
|
|
updateLog.value.push(operationType.value === 'update' ? '>>> tenant-service 已重启,更新完成 ✓' : '>>> tenant-service 已重启,重置完成 ✓')
|
2026-05-21 14:47:10 +08:00
|
|
|
|
return
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// still restarting
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
selfRestarting.value = false
|
|
|
|
|
|
updateError.value = '等待 tenant-service 重启超时,请手动刷新页面'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:55 +08:00
|
|
|
|
const fmt = formatTime
|
2026-05-01 21:27:39 +08:00
|
|
|
|
|
2026-05-21 14:47:10 +08:00
|
|
|
|
onMounted(async () => {
|
2026-05-22 19:33:38 +08:00
|
|
|
|
updateViewport()
|
|
|
|
|
|
window.addEventListener('resize', updateViewport)
|
2026-05-21 14:47:10 +08:00
|
|
|
|
loadData()
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = await getDeploymentStatus()
|
|
|
|
|
|
deploymentMode.value = status.mode
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
deploymentMode.value = 'PUBLIC'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-22 19:33:38 +08:00
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('resize', updateViewport)
|
|
|
|
|
|
})
|
2026-05-01 21:27:39 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.subtitle {
|
|
|
|
|
|
margin: 6px 0 0;
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-card {
|
|
|
|
|
|
min-height: 110px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dialog-text {
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
margin: 0 0 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.secret-box {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
2026-05-19 15:12:56 +08:00
|
|
|
|
.migrate-key-box {
|
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
background: #f5f7fa;
|
|
|
|
|
|
border: 1px solid #dcdfe6;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
user-select: all;
|
|
|
|
|
|
}
|
2026-05-21 14:47:10 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-05-01 21:27:39 +08:00
|
|
|
|
</style>
|