feat(system): 添加系统更新检查和选择性更新功能
- 新增 streamSelectiveUpdate 函数支持选择性服务更新 - 添加 UpdateCheckResult 接口定义版本更新检查结果 - 实现 checkForUpdates 函数获取版本更新信息 - 在安全中心视图添加版本信息显示和更新检查按钮 - 添加选择性更新对话框支持按服务选择更新 - 实现版本管理视图中的实时更新通知配置 - 添加从 IM 导入灰度成员功能 - 移除应用密钥管理相关的重复组件实现
这个提交包含在:
父节点
8abcf59147
当前提交
90c9705ff1
@ -86,10 +86,29 @@ export function streamSystemUpdate(onLine: (line: string) => void, signal?: Abor
|
|||||||
return streamOperation('/system/update', onLine, signal)
|
return streamOperation('/system/update', onLine, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function streamSelectiveUpdate(services: string[], onLine: (line: string) => void, signal?: AbortSignal) {
|
||||||
|
const qs = services.map(s => `services=${encodeURIComponent(s)}`).join('&')
|
||||||
|
return streamOperation(`/system/update-selective${qs ? '?' + qs : ''}`, onLine, signal)
|
||||||
|
}
|
||||||
|
|
||||||
export function streamSystemReset(onLine: (line: string) => void, signal?: AbortSignal) {
|
export function streamSystemReset(onLine: (line: string) => void, signal?: AbortSignal) {
|
||||||
return streamOperation('/system/reset', onLine, signal)
|
return streamOperation('/system/reset', onLine, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
currentVersion: string
|
||||||
|
latestVersion: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
releasedAt: string
|
||||||
|
changelog: string
|
||||||
|
services: Record<string, { current: string; latest: string; changed: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
||||||
|
const json = await authFetch('/system/check-update')
|
||||||
|
return json as UpdateCheckResult
|
||||||
|
}
|
||||||
|
|
||||||
// ── Database Management (PRIVATE mode only) ──────────────────────────────
|
// ── Database Management (PRIVATE mode only) ──────────────────────────────
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
|
|||||||
@ -172,6 +172,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- API Key 管理 -->
|
||||||
|
<el-card class="info-card" style="margin-top:16px">
|
||||||
|
<template #header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<span>API Key 管理</span>
|
||||||
|
<el-button size="small" type="primary" @click="openCreateApiKey" :disabled="apiKeys.length >= 8">
|
||||||
|
创建 API Key
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p style="color:#606266;font-size:13px;margin-bottom:12px">
|
||||||
|
API Key 用于外部工具(Postman / CI/CD)调用平台 API 进行认证,支持 update / push / im / license 等所有服务。每个应用最多 8 个,创建后仅显示一次。
|
||||||
|
</p>
|
||||||
|
<el-table :data="apiKeys" v-loading="loadingApiKeys" border stripe>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column label="API Key" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-text class="mono">{{ row.apiKey }}</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.enabled ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link :type="row.enabled ? 'warning' : 'success'" size="small" @click="toggleApiKey(row)">
|
||||||
|
{{ row.enabled ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="deleteApiKey(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Create API Key Dialog -->
|
||||||
|
<el-dialog v-model="showCreateApiKey" title="创建 API Key" :width="dialogWidth">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="newApiKeyName" placeholder="如:CI Pipeline、Postman 测试(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateApiKey = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="creatingApiKey" @click="submitCreateApiKey">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- API Key Created Result Dialog -->
|
||||||
|
<el-dialog v-model="showApiKeyResult" title="API Key 已创建" :width="dialogWidth" :close-on-click-modal="false">
|
||||||
|
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||||
|
<template #title>请立即复制保存,关闭后无法再次查看完整内容</template>
|
||||||
|
</el-alert>
|
||||||
|
<div class="api-key-result-box">{{ createdApiKey }}</div>
|
||||||
|
<el-button type="primary" plain size="small" style="margin-top:12px" @click="copyApiKey">复制 API Key</el-button>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="showApiKeyResult = false">我已保存,关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Email Verify Dialog (reveal or reset) -->
|
<!-- Email Verify Dialog (reveal or reset) -->
|
||||||
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
|
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
|
||||||
<div v-if="!codeSent">
|
<div v-if="!codeSent">
|
||||||
@ -251,6 +317,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { View } from '@element-plus/icons-vue'
|
import { View } from '@element-plus/icons-vue'
|
||||||
import { appApi, type App, type FeatureService } from '@/api/app'
|
import { appApi, type App, type FeatureService } from '@/api/app'
|
||||||
import client from '@/api/client'
|
import client from '@/api/client'
|
||||||
|
import { formatTime } from '@/utils/date'
|
||||||
import {
|
import {
|
||||||
connectServiceActivationRealtime,
|
connectServiceActivationRealtime,
|
||||||
disconnectServiceActivationRealtime,
|
disconnectServiceActivationRealtime,
|
||||||
@ -278,6 +345,87 @@ const submittingActivation = ref(false)
|
|||||||
const activationForm = ref({ platform: '', serviceType: '', reason: '' })
|
const activationForm = ref({ platform: '', serviceType: '', reason: '' })
|
||||||
const regenerating = ref(false)
|
const regenerating = ref(false)
|
||||||
|
|
||||||
|
// ── API Key Management ──
|
||||||
|
const apiKeys = ref<{ id: string; appKey: string; apiKey: string; name: string; enabled: boolean; createdAt: string }[]>([])
|
||||||
|
const loadingApiKeys = ref(false)
|
||||||
|
const showCreateApiKey = ref(false)
|
||||||
|
const creatingApiKey = ref(false)
|
||||||
|
const newApiKeyName = ref('')
|
||||||
|
const showApiKeyResult = ref(false)
|
||||||
|
const createdApiKey = ref('')
|
||||||
|
|
||||||
|
async function loadApiKeys() {
|
||||||
|
if (!app.value) return
|
||||||
|
loadingApiKeys.value = true
|
||||||
|
try {
|
||||||
|
const res = await client.get(`/apps/${app.value.appKey}/api-keys`)
|
||||||
|
apiKeys.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
apiKeys.value = []
|
||||||
|
} finally {
|
||||||
|
loadingApiKeys.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateApiKey() {
|
||||||
|
newApiKeyName.value = ''
|
||||||
|
showCreateApiKey.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateApiKey() {
|
||||||
|
if (!app.value) return
|
||||||
|
creatingApiKey.value = true
|
||||||
|
try {
|
||||||
|
const res = await client.post(`/apps/${app.value.appKey}/api-keys`, {
|
||||||
|
name: newApiKeyName.value.trim() || undefined,
|
||||||
|
})
|
||||||
|
createdApiKey.value = res.data.data.apiKey
|
||||||
|
showCreateApiKey.value = false
|
||||||
|
showApiKeyResult.value = true
|
||||||
|
await loadApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
creatingApiKey.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleApiKey(key: { id: string; enabled: boolean }) {
|
||||||
|
if (!app.value) return
|
||||||
|
try {
|
||||||
|
await client.patch(`/apps/${app.value.appKey}/api-keys/${key.id}`, {
|
||||||
|
enabled: !key.enabled,
|
||||||
|
})
|
||||||
|
ElMessage.success(key.enabled ? '已禁用' : '已启用')
|
||||||
|
await loadApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApiKey(key: { id: string; name: string }) {
|
||||||
|
if (!app.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除 API Key${key.name ? ` "${key.name}"` : ''}?删除后不可恢复。`, '删除 API Key', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await client.delete(`/apps/${app.value.appKey}/api-keys/${key.id}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyApiKey() {
|
||||||
|
navigator.clipboard.writeText(createdApiKey.value)
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
}
|
||||||
|
|
||||||
const showEditDialog = ref(false)
|
const showEditDialog = ref(false)
|
||||||
const savingEdit = ref(false)
|
const savingEdit = ref(false)
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
@ -360,6 +508,7 @@ async function loadData() {
|
|||||||
app.value = appRes.data.data
|
app.value = appRes.data.data
|
||||||
services.value = svcRes.data.data
|
services.value = svcRes.data.data
|
||||||
revealedSecret.value = null
|
revealedSecret.value = null
|
||||||
|
loadApiKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onToggleService(svcType: string, enable: boolean) {
|
async function onToggleService(svcType: string, enable: boolean) {
|
||||||
@ -524,6 +673,17 @@ onBeforeUnmount(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.mono { font-family: monospace; font-size: 12px; }
|
.mono { font-family: monospace; font-size: 12px; }
|
||||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
|
||||||
|
.api-key-result-box {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
.info-card :deep(.el-descriptions__body) {
|
.info-card :deep(.el-descriptions__body) {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>安全中心</h2>
|
<h2>安全中心</h2>
|
||||||
<p class="subtitle">管理登录安全、应用密钥和子账号风险控制。</p>
|
<p class="subtitle">管理登录安全、子账号风险控制和系统运维。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="$router.push('/accounts')">子账号管理</el-button>
|
<el-button @click="$router.push('/accounts')">子账号管理</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -32,7 +32,6 @@
|
|||||||
<el-tag type="success">强密码</el-tag>
|
<el-tag type="success">强密码</el-tag>
|
||||||
<el-tag type="warning">邮箱验证</el-tag>
|
<el-tag type="warning">邮箱验证</el-tag>
|
||||||
<el-tag type="info">子账号最小权限</el-tag>
|
<el-tag type="info">子账号最小权限</el-tag>
|
||||||
<el-tag type="info">密钥定期轮换</el-tag>
|
|
||||||
</el-space>
|
</el-space>
|
||||||
<el-divider />
|
<el-divider />
|
||||||
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
|
<el-button type="primary" plain @click="$router.push('/forgot-password')">重置当前账号密码</el-button>
|
||||||
@ -55,16 +54,42 @@
|
|||||||
<el-tag type="info" size="small">私有化部署</el-tag>
|
<el-tag type="info" size="small">私有化部署</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="1" border>
|
<!-- 版本信息 -->
|
||||||
<el-descriptions-item label="一键更新">
|
<el-descriptions :column="1" border style="margin-bottom:16px">
|
||||||
<span style="color:#606266;font-size:13px;margin-right:16px">拉取最新镜像并重建所有容器,用于升级到新版本。</span>
|
<el-descriptions-item label="当前版本">
|
||||||
<el-button type="primary" size="small" @click="openOperationDialog('update')">立即更新</el-button>
|
<el-tag v-if="currentVersion" type="info" size="small">{{ currentVersion }}</el-tag>
|
||||||
|
<span v-else style="color:#c0c4cc">加载中...</span>
|
||||||
|
<el-button size="small" style="margin-left:12px" @click="checkUpdate" :loading="checkingUpdate">检查更新</el-button>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="重置容器">
|
<el-descriptions-item v-if="updateCheckResult?.hasUpdate" label="最新版本">
|
||||||
<span style="color:#606266;font-size:13px;margin-right:16px">用当前本地镜像重建所有容器,无需下载新镜像,适合修复异常服务。</span>
|
<el-tag type="success" size="small">{{ updateCheckResult.latestVersion }}</el-tag>
|
||||||
<el-button type="warning" size="small" @click="openOperationDialog('reset')">重置容器</el-button>
|
<span v-if="updateCheckResult.releasedAt" style="color:#909399;font-size:12px;margin-left:8px">
|
||||||
|
{{ formatTime(updateCheckResult.releasedAt) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="updateCheckResult?.hasUpdate && updateCheckResult.changelog" label="更新日志">
|
||||||
|
<div style="white-space:pre-wrap;font-size:13px;color:#606266;line-height:1.6">{{ updateCheckResult.changelog }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="updateCheckResult?.hasUpdate" label="变更服务">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||||
|
<el-tag
|
||||||
|
v-for="(info, svc) in updateCheckResult.services"
|
||||||
|
:key="svc"
|
||||||
|
:type="info.changed ? 'warning' : 'info'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ svc }} {{ info.current }}{{ info.changed ? ' → ' + info.latest : '' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<el-button type="primary" size="small" @click="openOperationDialog('update')">
|
||||||
|
{{ updateCheckResult?.hasUpdate ? '一键更新到最新版本' : '一键更新' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="updateCheckResult?.hasUpdate" size="small" @click="openSelectiveUpdateDialog">选择性更新</el-button>
|
||||||
|
<el-button type="warning" size="small" @click="openOperationDialog('reset')">重置容器</el-button>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Config 文件解析 -->
|
<!-- Config 文件解析 -->
|
||||||
@ -104,50 +129,6 @@
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 应用密钥操作 dialog -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Migration dialogs -->
|
<!-- Migration dialogs -->
|
||||||
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
|
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
|
||||||
@ -188,19 +169,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- AppSecret result dialog -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 一键更新 / 重置容器 dialog -->
|
<!-- 一键更新 / 重置容器 dialog -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showUpdateDialog"
|
v-model="showUpdateDialog"
|
||||||
@ -256,6 +224,28 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 选择性更新 dialog -->
|
||||||
|
<el-dialog v-model="showSelectiveUpdate" title="选择性更新" width="500px">
|
||||||
|
<p style="color:#606266;margin-bottom:16px">选择要更新的服务(只拉取并重建选中的服务):</p>
|
||||||
|
<el-checkbox-group v-model="selectedServices">
|
||||||
|
<div v-for="(info, svc) in (updateCheckResult?.services ?? {})" :key="svc" style="padding:6px 0">
|
||||||
|
<el-checkbox :value="svc" :disabled="!info.changed">
|
||||||
|
<span>{{ svc }}</span>
|
||||||
|
<el-tag v-if="info.changed" type="warning" size="small" style="margin-left:8px">
|
||||||
|
{{ info.current }} → {{ info.latest }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small" style="margin-left:8px">无变更</el-tag>
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-checkbox-group>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showSelectiveUpdate = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedServices.length" @click="startSelectiveUpdate">
|
||||||
|
更新选中服务 ({{ selectedServices.length }})
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -266,7 +256,7 @@ 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, getSystemVersion, streamSystemUpdate, streamSystemReset } from '@/api/system'
|
import { getDeploymentStatus, getSystemVersion, streamSystemUpdate, streamSelectiveUpdate, streamSystemReset, checkForUpdates } from '@/api/system'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatTime } from '@/utils/date'
|
import { formatTime } from '@/utils/date'
|
||||||
|
|
||||||
@ -276,16 +266,6 @@ const loading = ref(false)
|
|||||||
const subAccountCount = ref(0)
|
const subAccountCount = ref(0)
|
||||||
const deploymentMode = ref<'PUBLIC' | 'PRIVATE' | null>(null)
|
const deploymentMode = ref<'PUBLIC' | 'PRIVATE' | null>(null)
|
||||||
|
|
||||||
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('')
|
|
||||||
|
|
||||||
// Config file parse
|
// Config file parse
|
||||||
const configFileContent = ref('')
|
const configFileContent = ref('')
|
||||||
const parsingConfig = ref(false)
|
const parsingConfig = ref(false)
|
||||||
@ -348,53 +328,6 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
await appApi.requestSecretVerify(selectedApp.value.appKey, dialogMode.value)
|
|
||||||
codeSent.value = true
|
|
||||||
ElMessage.success('验证码已发送到邮箱')
|
|
||||||
} finally {
|
|
||||||
sendingCode.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitVerify() {
|
|
||||||
if (!selectedApp.value) return
|
|
||||||
if (!verifyCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
if (dialogMode.value === 'REVEAL_SECRET') {
|
|
||||||
const res = await appApi.revealSecret(selectedApp.value.appKey, verifyCode.value.trim())
|
|
||||||
secretResult.value = res.data.data.appSecret
|
|
||||||
} else {
|
|
||||||
const res = await appApi.resetSecret(selectedApp.value.appKey, verifyCode.value.trim())
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Migration ─────────────────────────────────────────────────────────────
|
// ── Migration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const showMigrateDialog = ref(false)
|
const showMigrateDialog = ref(false)
|
||||||
@ -461,6 +394,26 @@ const updateLog = ref<string[]>([])
|
|||||||
const logEl = ref<HTMLPreElement | null>(null)
|
const logEl = ref<HTMLPreElement | null>(null)
|
||||||
const currentVersion = ref('')
|
const currentVersion = ref('')
|
||||||
const versionLoading = ref(false)
|
const versionLoading = ref(false)
|
||||||
|
const checkingUpdate = ref(false)
|
||||||
|
const updateCheckResult = ref<import('@/api/system').UpdateCheckResult | null>(null)
|
||||||
|
|
||||||
|
async function checkUpdate() {
|
||||||
|
checkingUpdate.value = true
|
||||||
|
try {
|
||||||
|
const result = await checkForUpdates()
|
||||||
|
updateCheckResult.value = result
|
||||||
|
currentVersion.value = result.currentVersion
|
||||||
|
if (result.hasUpdate) {
|
||||||
|
ElMessage.info(`发现新版本 ${result.latestVersion}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('已是最新版本')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('检查更新失败')
|
||||||
|
} finally {
|
||||||
|
checkingUpdate.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openOperationDialog(type: 'update' | 'reset') {
|
async function openOperationDialog(type: 'update' | 'reset') {
|
||||||
operationType.value = type
|
operationType.value = type
|
||||||
@ -478,6 +431,66 @@ async function openOperationDialog(type: 'update' | 'reset') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Selective Update ──
|
||||||
|
const showSelectiveUpdate = ref(false)
|
||||||
|
const selectedServices = ref<string[]>([])
|
||||||
|
|
||||||
|
function openSelectiveUpdateDialog() {
|
||||||
|
// 默认选中所有有变更的服务
|
||||||
|
const changed = updateCheckResult.value?.services
|
||||||
|
? Object.entries(updateCheckResult.value.services)
|
||||||
|
.filter(([_, info]) => info.changed)
|
||||||
|
.map(([svc]) => svc)
|
||||||
|
: []
|
||||||
|
selectedServices.value = changed
|
||||||
|
showSelectiveUpdate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSelectiveUpdate() {
|
||||||
|
if (!selectedServices.value.length) return
|
||||||
|
showSelectiveUpdate.value = false
|
||||||
|
// 复用更新对话框显示日志
|
||||||
|
operationType.value = 'update'
|
||||||
|
showUpdateDialog.value = true
|
||||||
|
updating.value = true
|
||||||
|
updateLog.value = []
|
||||||
|
updateDone.value = false
|
||||||
|
updateError.value = ''
|
||||||
|
selfRestarting.value = false
|
||||||
|
|
||||||
|
let seenRestartSelf = false
|
||||||
|
try {
|
||||||
|
await streamSelectiveUpdate(selectedServices.value, (line) => {
|
||||||
|
if (line === 'RESTART_SELF') {
|
||||||
|
seenRestartSelf = true
|
||||||
|
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 (seenRestartSelf || selfRestarting.value) {
|
||||||
|
if (!selfRestarting.value) {
|
||||||
|
selfRestarting.value = true
|
||||||
|
updating.value = false
|
||||||
|
pollForRecovery()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updating.value = false
|
||||||
|
updateError.value = e?.message ?? '选择性更新失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetUpdateDialog() {
|
function resetUpdateDialog() {
|
||||||
if (updating.value) return
|
if (updating.value) return
|
||||||
updateLog.value = []
|
updateLog.value = []
|
||||||
@ -572,6 +585,10 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
const status = await getDeploymentStatus()
|
const status = await getDeploymentStatus()
|
||||||
deploymentMode.value = status.mode
|
deploymentMode.value = status.mode
|
||||||
|
if (status.mode === 'PRIVATE') {
|
||||||
|
const info = await getSystemVersion()
|
||||||
|
currentVersion.value = info.currentVersion
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
deploymentMode.value = 'PUBLIC'
|
deploymentMode.value = 'PUBLIC'
|
||||||
}
|
}
|
||||||
@ -597,15 +614,6 @@ onUnmounted(() => {
|
|||||||
.summary-card {
|
.summary-card {
|
||||||
min-height: 110px;
|
min-height: 110px;
|
||||||
}
|
}
|
||||||
.dialog-text {
|
|
||||||
color: #606266;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
}
|
|
||||||
.secret-box {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-family: monospace;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.migrate-key-box {
|
.migrate-key-box {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@ -346,6 +346,10 @@
|
|||||||
<el-form-item label="更新免登录">
|
<el-form-item label="更新免登录">
|
||||||
<el-switch v-model="publishConfigForm.allowAnonymousUpdateCheck" />
|
<el-switch v-model="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="实时更新通知">
|
||||||
|
<el-switch v-model="publishConfigForm.enableRealtimeNotification" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
||||||
|
<span class="form-tip" style="margin-left:8px">开启后发布新版本时,已打开的 App 会立即收到通知</span>
|
||||||
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="publishConfigForm.allowAnonymousUpdateCheck"
|
v-if="publishConfigForm.allowAnonymousUpdateCheck"
|
||||||
type="warning"
|
type="warning"
|
||||||
@ -455,6 +459,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
<el-empty v-if="!grayTags.length" description="暂无标签,请先在成员管理中创建" :image-size="40" />
|
<el-empty v-if="!grayTags.length" description="暂无标签,请先在成员管理中创建" :image-size="40" />
|
||||||
|
<div style="margin-top:8px;display:flex;gap:8px">
|
||||||
|
<el-button size="small" @click="importGrayFromIm" :loading="importingIm">从 IM 导入成员</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<!-- 额外指定成员 -->
|
<!-- 额外指定成员 -->
|
||||||
@ -1781,6 +1788,7 @@ const submittingGray = ref(false)
|
|||||||
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
|
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
|
||||||
const grayTags = ref<GrayTag[]>([])
|
const grayTags = ref<GrayTag[]>([])
|
||||||
const grayExtraInput = ref('')
|
const grayExtraInput = ref('')
|
||||||
|
const importingIm = ref(false)
|
||||||
const grayForm = ref({
|
const grayForm = ref({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
grayMode: 'PERCENT' as GrayMode,
|
grayMode: 'PERCENT' as GrayMode,
|
||||||
@ -1848,6 +1856,20 @@ async function loadGrayTags() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importGrayFromIm() {
|
||||||
|
importingIm.value = true
|
||||||
|
try {
|
||||||
|
const res = await updateAdminApi.importGrayMembersFromIm(appKey.value)
|
||||||
|
const { added, updated, removed } = res.data.data
|
||||||
|
ElMessage.success(`IM 导入完成:新增 ${added},更新 ${updated},移除 ${removed}`)
|
||||||
|
await loadGrayTags()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || 'IM 导入失败')
|
||||||
|
} finally {
|
||||||
|
importingIm.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitGray() {
|
async function submitGray() {
|
||||||
if (!grayTarget.value) return
|
if (!grayTarget.value) return
|
||||||
if (allowAnonymousUpdateCheck.value) {
|
if (allowAnonymousUpdateCheck.value) {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户