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-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
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户