feat(tenant-platform): migration key UI + app market publish options

- Add migrate API client (request-code / generate-key)
- SecurityCenterView: 私有化部署迁移 card with email verify → one-time key dialog
- VersionManagementView: remove preflight check dialog; open submit directly
- VersionManagementView: replace 手动上架/审核后自动/定时 with 立即上架/计划上架 only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-19 15:12:56 +08:00
父节点 b6f2aedc70
当前提交 3900d25d51
共有 3 个文件被更改,包括 132 次插入106 次删除

查看文件

@ -0,0 +1,8 @@
import client from './client'
export const migrateApi = {
requestCode: () => client.post('/migrate/request-code'),
generateKey: (code: string) =>
client.post<{ data: { migrationKey: string } }>('/migrate/generate-key', { code }),
}

查看文件

@ -38,6 +38,14 @@
<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">
<template #header>私有化部署迁移</template>
<p style="color: #606266; margin: 0 0 16px;">
将当前账号及应用数据迁移至私有化部署环境迁移密钥仅在生成时显示一次请及时保存
</p>
<el-button type="warning" plain @click="openMigrateDialog">生成迁移密钥</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>
@ -82,6 +90,46 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Migration: step 1 request email code -->
<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>
<!-- Migration: step 2 display one-time key -->
<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>
<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>
@ -101,6 +149,7 @@ import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
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 { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@ -179,6 +228,63 @@ function closeDialog() {
codeSent.value = false codeSent.value = false
} }
// Migration
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() {
if (!migrateCode.value.trim()) {
ElMessage.warning('请输入验证码')
return
}
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('复制失败,请手动选择并复制')
}
}
function fmt(value: string) { function fmt(value: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-' return value ? new Date(value).toLocaleString('zh-CN') : '-'
} }
@ -210,4 +316,15 @@ onMounted(loadData)
font-family: monospace; font-family: monospace;
word-break: break-all; word-break: break-all;
} }
.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;
}
</style> </style>

查看文件

@ -494,9 +494,8 @@
<el-form label-width="120px" style="margin-bottom:12px"> <el-form label-width="120px" style="margin-bottom:12px">
<el-form-item label="上架方式"> <el-form-item label="上架方式">
<el-radio-group v-model="submitStoreMode"> <el-radio-group v-model="submitStoreMode">
<el-radio-button value="MANUAL">手动上架</el-radio-button> <el-radio-button value="AUTO_REVIEW">立即上架</el-radio-button>
<el-radio-button value="AUTO_REVIEW">审核完成自动上架</el-radio-button> <el-radio-button value="SCHEDULED">计划上架</el-radio-button>
<el-radio-button value="SCHEDULED">定时上架</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="submitStoreMode === 'SCHEDULED'" label="计划时间"> <el-form-item v-if="submitStoreMode === 'SCHEDULED'" label="计划时间">
@ -549,56 +548,6 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Preflight Dialog -->
<el-dialog v-model="showPreflight" title="提审前检查" :width="isMobile ? '100%' : '560px'">
<div v-if="preflightLoading" v-loading="true" style="min-height:120px" />
<div v-else-if="preflightResult">
<p>版本 <strong>{{ preflightResult.versionName }}</strong> 的前置检查结果</p>
<el-table :data="preflightResult.stores" border size="small" style="margin-top:12px">
<el-table-column prop="storeType" label="厂商" width="100">
<template #default="{row}">{{ storeLabel(row.storeType) }}</template>
</el-table-column>
<el-table-column label="线上版本" width="120">
<template #default="{row}">
<span v-if="row.onlineVersionCode">{{ row.onlineVersionName || row.onlineVersionCode }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="160">
<template #default="{row}">
<el-tag :type="preflightTagType(row.reviewState)" size="small">
{{ row.nonCurrentRelease ? '已上线(非本次发布)' : preflightStateLabel(row.reviewState) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="是否允许" width="90">
<template #default="{row}">
<el-tag :type="row.canSubmit ? 'success' : 'danger'" size="small">
{{ row.canSubmit ? '允许' : '阻止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="140">
<template #default="{row}">
<span v-if="row.blockReason" class="text-danger">{{ row.blockReason }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
<el-checkbox v-model="preflightSkipCheck" style="margin-top:12px" v-if="preflightResult.stores.some(s => !s.canSubmit)">
跳过厂商状态检查后继续提交管理员
</el-checkbox>
</div>
<template #footer>
<el-button @click="showPreflight = false">取消</el-button>
<el-button
type="primary"
:disabled="preflightResult ? !preflightResult.stores.every(s => s.canSubmit) && !preflightSkipCheck : true"
@click="showPreflight = false; showSubmitStore = true"
>继续提交</el-button>
</template>
</el-dialog>
<!-- Store Review Detail Dialog --> <!-- Store Review Detail Dialog -->
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll"> <el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll">
<div v-if="storeReviewDetailVersion"> <div v-if="storeReviewDetailVersion">
@ -987,7 +936,6 @@ import {
type GrayMemberGroup, type GrayMemberGroup,
type GrayMode, type GrayMode,
type GraySelectionSource, type GraySelectionSource,
type PreflightSubmitResultDto,
type PublishMode, type PublishMode,
type RnBundle, type RnBundle,
type RnBundleInspectResult, type RnBundleInspectResult,
@ -1555,15 +1503,9 @@ const showSubmitStore = ref(false)
const submittingToStores = ref(false) const submittingToStores = ref(false)
const submitStoreVersion = ref<AppVersion | null>(null) const submitStoreVersion = ref<AppVersion | null>(null)
const selectedStores = ref<StoreType[]>([]) const selectedStores = ref<StoreType[]>([])
const submitStoreMode = ref<PublishMode>('MANUAL') const submitStoreMode = ref<PublishMode>('AUTO_REVIEW')
const submitStoreScheduledAt = ref('') const submitStoreScheduledAt = ref('')
// Preflight dialog state
const showPreflight = ref(false)
const preflightLoading = ref(false)
const preflightResult = ref<PreflightSubmitResultDto | null>(null)
const preflightSkipCheck = ref(false)
const showStoreReviewDetail = ref(false) const showStoreReviewDetail = ref(false)
const storeReviewDetailVersion = ref<AppVersion | null>(null) const storeReviewDetailVersion = ref<AppVersion | null>(null)
type StoreReviewItem = { type StoreReviewItem = {
@ -1701,32 +1643,15 @@ async function handleUpdatePublishSchedule() {
} }
} }
async function openSubmitStoreDialog(row: AppVersion) { function openSubmitStoreDialog(row: AppVersion) {
submitStoreVersion.value = row submitStoreVersion.value = row
preflightResult.value = null
preflightSkipCheck.value = false
showPreflight.value = true
preflightLoading.value = true
try {
const res = await updateAdminApi.preflightStoreSubmission(row.id)
preflightResult.value = res.data.data
// Auto-open submit dialog if all allowed
const allAllowed = preflightResult.value?.stores.every(s => s.canSubmit) ?? false
if (allAllowed) {
showPreflight.value = false
showSubmitStore.value = true
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '前置检查失败')
} finally {
preflightLoading.value = false
}
// Fallback: pre-select stores
selectedStores.value = enabledStores.value selectedStores.value = enabledStores.value
.map(s => s.type) .map(s => s.type)
.filter(t => !isStoreActiveReview(row, t)) .filter(t => !isStoreActiveReview(row, t))
submitStoreMode.value = row.storeSubmitMode ?? 'MANUAL' const savedMode = row.storeSubmitMode
submitStoreMode.value = (savedMode === 'SCHEDULED' ? 'SCHEDULED' : 'AUTO_REVIEW') as PublishMode
submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? '' submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? ''
showSubmitStore.value = true
} }
function getSubmitDialogStoreState(version: AppVersion | null, storeType: string): string { function getSubmitDialogStoreState(version: AppVersion | null, storeType: string): string {
@ -2301,30 +2226,6 @@ function hasActiveStoreReview(row: AppVersion): boolean {
return items.some(i => i.state === 'SUBMITTING' || i.state === 'UNDER_REVIEW') return items.some(i => i.state === 'SUBMITTING' || i.state === 'UNDER_REVIEW')
} }
function preflightTagType(state: string): string {
return {
ONLINE: 'success',
UNDER_REVIEW: 'warning',
UNDER_REVIEW_XIAOMI: 'warning',
REJECTED: 'danger',
NOT_FOUND: 'info',
UNKNOWN: 'info',
QUERY_FAILED: 'danger',
}[state] ?? ''
}
function preflightStateLabel(state: string): string {
return {
ONLINE: '已上线',
UNDER_REVIEW: '审核中',
UNDER_REVIEW_XIAOMI: '审核中(版本号可能不准确)',
REJECTED: '已拒绝',
NOT_FOUND: '未找到',
UNKNOWN: '未知',
QUERY_FAILED: '查询失败',
}[state] ?? state
}
async function promptUnpublishRn(id: string) { async function promptUnpublishRn(id: string) {
const reason = await promptUnpublishReason('下架确认') const reason = await promptUnpublishReason('下架确认')
if (!reason) return if (!reason) return