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>
这个提交包含在:
父节点
b6f2aedc70
当前提交
3900d25d51
@ -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-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>
|
||||
<template #header>应用密钥管理</template>
|
||||
<el-table :data="apps" v-loading="loading" border stripe>
|
||||
@ -82,6 +90,46 @@
|
||||
</template>
|
||||
</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-alert type="success" :closable="false" show-icon>
|
||||
<template #title>操作已完成</template>
|
||||
@ -101,6 +149,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { accountApi } from '@/api/account'
|
||||
import { appApi, type App } from '@/api/app'
|
||||
import { migrateApi } from '@/api/migrate'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@ -179,6 +228,63 @@ function closeDialog() {
|
||||
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) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
@ -210,4 +316,15 @@ onMounted(loadData)
|
||||
font-family: monospace;
|
||||
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>
|
||||
|
||||
@ -494,9 +494,8 @@
|
||||
<el-form label-width="120px" style="margin-bottom:12px">
|
||||
<el-form-item label="上架方式">
|
||||
<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="SCHEDULED">定时上架</el-radio-button>
|
||||
<el-radio-button value="AUTO_REVIEW">立即上架</el-radio-button>
|
||||
<el-radio-button value="SCHEDULED">计划上架</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="submitStoreMode === 'SCHEDULED'" label="计划时间">
|
||||
@ -549,56 +548,6 @@
|
||||
</template>
|
||||
</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 -->
|
||||
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll">
|
||||
<div v-if="storeReviewDetailVersion">
|
||||
@ -987,7 +936,6 @@ import {
|
||||
type GrayMemberGroup,
|
||||
type GrayMode,
|
||||
type GraySelectionSource,
|
||||
type PreflightSubmitResultDto,
|
||||
type PublishMode,
|
||||
type RnBundle,
|
||||
type RnBundleInspectResult,
|
||||
@ -1555,15 +1503,9 @@ const showSubmitStore = ref(false)
|
||||
const submittingToStores = ref(false)
|
||||
const submitStoreVersion = ref<AppVersion | null>(null)
|
||||
const selectedStores = ref<StoreType[]>([])
|
||||
const submitStoreMode = ref<PublishMode>('MANUAL')
|
||||
const submitStoreMode = ref<PublishMode>('AUTO_REVIEW')
|
||||
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 storeReviewDetailVersion = ref<AppVersion | null>(null)
|
||||
type StoreReviewItem = {
|
||||
@ -1701,32 +1643,15 @@ async function handleUpdatePublishSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSubmitStoreDialog(row: AppVersion) {
|
||||
function openSubmitStoreDialog(row: AppVersion) {
|
||||
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
|
||||
.map(s => s.type)
|
||||
.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 ?? ''
|
||||
showSubmitStore.value = true
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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) {
|
||||
const reason = await promptUnpublishReason('下架确认')
|
||||
if (!reason) return
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户