feat(system): 添加系统更新检查和选择性更新功能

- 新增 streamSelectiveUpdate 函数支持选择性服务更新
- 添加 UpdateCheckResult 接口定义版本更新检查结果
- 实现 checkForUpdates 函数获取版本更新信息
- 在安全中心视图添加版本信息显示和更新检查按钮
- 添加选择性更新对话框支持按服务选择更新
- 实现版本管理视图中的实时更新通知配置
- 添加从 IM 导入灰度成员功能
- 移除应用密钥管理相关的重复组件实现
这个提交包含在:
XuqmGroup 2026-06-11 14:11:06 +08:00
父节点 8abcf59147
当前提交 90c9705ff1
共有 4 个文件被更改,包括 342 次插入133 次删除

查看文件

@ -86,10 +86,29 @@ export function streamSystemUpdate(onLine: (line: string) => void, signal?: Abor
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) {
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) ──────────────────────────────
export interface TableInfo {

查看文件

@ -172,6 +172,72 @@
</div>
</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) -->
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
<div v-if="!codeSent">
@ -251,6 +317,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
import { appApi, type App, type FeatureService } from '@/api/app'
import client from '@/api/client'
import { formatTime } from '@/utils/date'
import {
connectServiceActivationRealtime,
disconnectServiceActivationRealtime,
@ -278,6 +345,87 @@ const submittingActivation = ref(false)
const activationForm = ref({ platform: '', serviceType: '', reason: '' })
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 savingEdit = ref(false)
const editForm = ref({
@ -360,6 +508,7 @@ async function loadData() {
app.value = appRes.data.data
services.value = svcRes.data.data
revealedSecret.value = null
loadApiKeys()
}
async function onToggleService(svcType: string, enable: boolean) {
@ -524,6 +673,17 @@ onBeforeUnmount(() => {
<style scoped>
.mono { font-family: monospace; font-size: 12px; }
.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) {
overflow-x: auto;
}

查看文件

@ -3,7 +3,7 @@
<div class="page-header">
<div>
<h2>安全中心</h2>
<p class="subtitle">管理登录安全应用密钥和子账号风险控制</p>
<p class="subtitle">管理登录安全子账号风险控制和系统运维</p>
</div>
<el-button @click="$router.push('/accounts')">子账号管理</el-button>
</div>
@ -32,7 +32,6 @@
<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>
@ -55,16 +54,42 @@
<el-tag type="info" size="small">私有化部署</el-tag>
</div>
</template>
<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 :column="1" border style="margin-bottom:16px">
<el-descriptions-item label="当前版本">
<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 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 v-if="updateCheckResult?.hasUpdate" label="最新版本">
<el-tag type="success" size="small">{{ updateCheckResult.latestVersion }}</el-tag>
<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>
<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>
<!-- Config 文件解析 -->
@ -104,50 +129,6 @@
</el-descriptions>
</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 -->
<el-dialog v-model="showMigrateDialog" title="私有化部署迁移" width="460px" @closed="closeMigrateDialog">
@ -188,19 +169,6 @@
</template>
</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 -->
<el-dialog
v-model="showUpdateDialog"
@ -256,6 +224,28 @@
</el-button>
</template>
</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>
</template>
@ -266,7 +256,7 @@ import { Loading } from '@element-plus/icons-vue'
import { accountApi } from '@/api/account'
import { appApi, type App } from '@/api/app'
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 { formatTime } from '@/utils/date'
@ -276,16 +266,6 @@ const loading = ref(false)
const subAccountCount = ref(0)
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
const configFileContent = ref('')
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
const showMigrateDialog = ref(false)
@ -461,6 +394,26 @@ const updateLog = ref<string[]>([])
const logEl = ref<HTMLPreElement | null>(null)
const currentVersion = ref('')
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') {
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() {
if (updating.value) return
updateLog.value = []
@ -572,6 +585,10 @@ onMounted(async () => {
try {
const status = await getDeploymentStatus()
deploymentMode.value = status.mode
if (status.mode === 'PRIVATE') {
const info = await getSystemVersion()
currentVersion.value = info.currentVersion
}
} catch {
deploymentMode.value = 'PUBLIC'
}
@ -597,15 +614,6 @@ onUnmounted(() => {
.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;
}
.migrate-key-box {
font-family: monospace;
font-size: 14px;

查看文件

@ -346,6 +346,10 @@
<el-form-item label="更新免登录">
<el-switch v-model="publishConfigForm.allowAnonymousUpdateCheck" />
</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
v-if="publishConfigForm.allowAnonymousUpdateCheck"
type="warning"
@ -455,6 +459,9 @@
</div>
</el-checkbox-group>
<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>
</el-form-item>
<!-- 额外指定成员 -->
@ -1781,6 +1788,7 @@ const submittingGray = ref(false)
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
const grayTags = ref<GrayTag[]>([])
const grayExtraInput = ref('')
const importingIm = ref(false)
const grayForm = ref({
enabled: true,
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() {
if (!grayTarget.value) return
if (allowAnonymousUpdateCheck.value) {