XuqmGroup-Web/tenant-platform/src/views/update/VersionManagementView.vue

889 行
38 KiB
Vue

<template>
<div>
<el-page-header @back="$router.back()" :content="`版本管理 — ${appId}`" style="margin-bottom:20px" />
<el-card>
<div class="toolbar" style="margin-bottom: 16px">
<el-button type="primary" @click="showUnifiedUpload = true">一键上传</el-button>
</div>
<el-tabs v-model="activeTab">
<!-- App Versions -->
<el-tab-pane label="App 整包版本" name="app">
<div class="toolbar">
<el-radio-group v-model="appPlatform" @change="loadAppVersions" style="margin-right:12px">
<el-radio-button value="ANDROID">Android</el-radio-button>
<el-radio-button value="IOS">iOS</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="showUploadApp = true">上传新版本</el-button>
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
</div>
<el-table :data="appVersions" v-loading="loadingApp" border stripe>
<el-table-column prop="versionName" label="版本名" width="110" />
<el-table-column prop="versionCode" label="版本码" width="90" />
<el-table-column label="状态" width="140">
<template #default="{row}">
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
<el-tag v-if="row.grayEnabled" type="warning" size="small" style="margin-left:4px">
灰度 {{ row.grayPercent }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
<template #default="{row}">
<template v-if="parseStoreReview(row.storeReviewStatus).length">
<el-tag
v-for="item in parseStoreReview(row.storeReviewStatus)"
:key="item.store"
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</template>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column prop="forceUpdate" label="强制" width="70">
<template #default="{row}">
<el-tag :type="row.forceUpdate ? 'danger' : 'info'" size="small">
{{ row.forceUpdate ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="changeLog" label="更新说明" show-overflow-tooltip />
<el-table-column prop="createdAt" label="上传时间" width="160">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{row}">
<el-button
v-if="row.publishStatus === 'DRAFT'"
link type="success" size="small"
@click="publishApp(row.id)">发布</el-button>
<el-button
v-if="row.publishStatus === 'PUBLISHED'"
link type="warning" size="small"
@click="openGrayDialog(row, 'app')">灰度</el-button>
<el-button
v-if="row.publishStatus === 'PUBLISHED'"
link type="danger" size="small"
@click="unpublishApp(row.id)">下架</el-button>
<el-button
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
link type="primary" size="small"
@click="openSubmitStoreDialog(row)">提交市场</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- RN Bundles -->
<el-tab-pane label="RN Bundle 热更新" name="rn">
<div class="toolbar">
<el-input
v-model="rnModuleFilter"
placeholder="模块ID可选"
clearable
style="width:180px;margin-right:8px"
@change="loadRnBundles"
/>
<el-radio-group v-model="rnPlatform" @change="loadRnBundles" style="margin-right:12px">
<el-radio-button value="">全平台</el-radio-button>
<el-radio-button value="ANDROID">Android</el-radio-button>
<el-radio-button value="IOS">iOS</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="showUploadRn = true">上传 Bundle</el-button>
<el-button @click="loadRnBundles" :loading="loadingRn">刷新</el-button>
</div>
<el-table :data="rnBundles" v-loading="loadingRn" border stripe>
<el-table-column prop="moduleId" label="模块ID" width="140" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="platform" label="平台" width="90" />
<el-table-column label="状态" width="140">
<template #default="{row}">
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
<el-tag v-if="row.grayEnabled" type="warning" size="small" style="margin-left:4px">
灰度 {{ row.grayPercent }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="minCommonVersion" label="最低 Common 版本" width="160" />
<el-table-column prop="note" label="说明" show-overflow-tooltip />
<el-table-column prop="createdAt" label="上传时间" width="160">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{row}">
<el-button v-if="row.publishStatus === 'DRAFT'" link type="success" size="small" @click="publishRn(row.id)">发布</el-button>
<el-button v-if="row.publishStatus === 'PUBLISHED'" link type="warning" size="small" @click="openGrayDialog(row, 'rn')">灰度</el-button>
<el-button v-if="row.publishStatus === 'PUBLISHED'" link type="danger" size="small" @click="unpublishRn(row.id)">下架</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- App Store Config -->
<el-tab-pane label="应用商店配置" name="store">
<div class="store-grid">
<el-card
v-for="store in STORE_DEFS"
:key="store.type"
class="store-card"
shadow="hover"
>
<div class="store-card-header">
<span class="store-card-name">{{ store.label }}</span>
<el-switch
:model-value="isStoreEnabled(store.type)"
:disabled="!getStoreConfig(store.type)"
@change="toggleStore(store.type, $event as boolean)"
/>
</div>
<div class="store-card-status">
<el-tag v-if="getStoreConfig(store.type)" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</div>
<div class="store-card-footer">
<el-button size="small" @click="openStoreConfigDialog(store)">
{{ getStoreConfig(store.type) ? '编辑凭据' : '配置凭据' }}
</el-button>
<el-button
v-if="getStoreConfig(store.type)"
size="small" type="danger"
@click="removeStoreConfig(store.type)"
>删除</el-button>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Gray Release Dialog -->
<el-dialog v-model="showGray" title="灰度发布配置" width="400px">
<el-form label-width="90px">
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
<el-form-item label="灰度比例" v-if="grayForm.enabled">
<el-slider v-model="grayForm.percent" :min="1" :max="100" show-input />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showGray = false">取消</el-button>
<el-button type="primary" @click="submitGray" :loading="submittingGray">保存</el-button>
</template>
</el-dialog>
<!-- Submit to Store Dialog -->
<el-dialog v-model="showSubmitStore" title="提交应用市场" width="480px">
<div v-if="submitStoreVersion">
<p style="margin-bottom:12px">
版本 <strong>{{ submitStoreVersion.versionName }}</strong>
将由服务端自动提交至以下已配置且启用的市场
</p>
<el-checkbox-group v-model="selectedStores">
<div v-for="store in enabledStores" :key="store.type" class="store-checkbox-row">
<el-checkbox :value="store.type">{{ store.label }}</el-checkbox>
<el-tag size="small" type="success" style="margin-left:8px">已配置</el-tag>
</div>
</el-checkbox-group>
<el-alert
v-if="!enabledStores.length"
type="warning"
show-icon
:closable="false"
title="当前应用暂未配置任何应用商店凭据,请先在「应用商店配置」标签页中配置。"
style="margin-top:12px"
/>
</div>
<template #footer>
<el-button @click="showSubmitStore = false">取消</el-button>
<el-button
type="primary"
:disabled="!selectedStores.length"
@click="confirmSubmitToStores"
:loading="submittingToStores"
>提交审核</el-button>
</template>
</el-dialog>
<!-- Store Credential Config Dialog -->
<el-dialog
v-model="showStoreConfig"
:title="`配置 ${currentStoreDef?.label} 凭据`"
width="520px"
>
<el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="160px">
<el-form-item label="启用">
<el-switch v-model="storeConfigForm.enabled" />
</el-form-item>
<template v-for="field in currentStoreDef.fields" :key="field.key">
<el-form-item :label="field.label">
<el-input
v-if="field.type === 'password'"
v-model="storeConfigForm.values[field.key]"
type="password"
show-password
:placeholder="field.placeholder"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="storeConfigForm.values[field.key]"
type="textarea"
:rows="4"
:placeholder="field.placeholder"
/>
<el-input
v-else
v-model="storeConfigForm.values[field.key]"
:placeholder="field.placeholder"
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showStoreConfig = false">取消</el-button>
<el-button type="primary" @click="saveStoreConfig" :loading="savingStoreConfig">保存</el-button>
</template>
</el-dialog>
<!-- Upload App Version Dialog -->
<el-dialog v-model="showUploadApp" title="上传 App 版本" width="540px">
<el-form :model="appUploadForm" label-width="120px">
<el-divider content-position="left">基础信息</el-divider>
<el-form-item label="平台">
<el-select v-model="appUploadForm.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="包名"><el-input v-model="appUploadForm.packageName" placeholder="com.example.app" /></el-form-item>
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" /></el-form-item>
<el-form-item label="版本码"><el-input-number v-model="appUploadForm.versionCode" :min="1" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="appUploadForm.forceUpdate" /></el-form-item>
<el-form-item label="更新说明"><el-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => appUploadForm.file = f.raw ?? null" accept=".apk,.ipa">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-divider content-position="left">发版配置</el-divider>
<el-form-item label="定时发布">
<el-date-picker
v-model="appUploadForm.scheduledPublishAt"
type="datetime"
placeholder="留空则手动发布"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
/>
</el-form-item>
<el-form-item label="Webhook 通知">
<el-input v-model="appUploadForm.webhookUrl" placeholder="审核状态变更时回调此 URL" />
</el-form-item>
<el-form-item label="自动提交市场">
<el-switch v-model="appUploadForm.autoSubmitStore" />
<span class="form-tip">上传后立即让服务端提交已配置的应用商店</span>
</el-form-item>
<el-form-item v-if="appUploadForm.autoSubmitStore" label="目标市场">
<el-checkbox-group v-model="appUploadForm.storeTargets">
<el-checkbox v-for="s in enabledStores" :key="s.type" :value="s.type">{{ s.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="审核后自动发布">
<el-switch v-model="appUploadForm.autoPublishAfterReview" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadApp = false">取消</el-button>
<el-button type="primary" @click="submitAppUpload" :loading="uploadingApp">上传</el-button>
</template>
</el-dialog>
<!-- Upload RN Bundle Dialog -->
<el-dialog v-model="showUploadRn" title="上传 RN Bundle" width="480px">
<el-form :model="rnUploadForm" label-width="120px">
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" /></el-form-item>
<el-form-item label="平台">
<el-select v-model="rnUploadForm.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="如 1.0.1" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="如 1.0.0" /></el-form-item>
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => rnUploadForm.file = f.raw ?? null" accept=".bundle,.js">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadRn = false">取消</el-button>
<el-button type="primary" @click="submitRnUpload" :loading="uploadingRn">上传</el-button>
</template>
</el-dialog>
<!-- Unified Release Dialog -->
<el-dialog v-model="showUnifiedUpload" title="一键上传" width="960px">
<el-form label-width="120px">
<el-divider content-position="left">Android / iOS 整包</el-divider>
<div class="unified-grid">
<div v-for="item in unifiedAppForms" :key="item.platform" class="unified-block">
<div class="unified-block-title">{{ item.platform === 'ANDROID' ? 'Android' : 'iOS' }}</div>
<el-form-item label="启用"><el-switch v-model="item.enabled" /></el-form-item>
<template v-if="item.enabled">
<el-form-item label="包名"><el-input v-model="item.packageName" placeholder="com.example.app" /></el-form-item>
<el-form-item label="版本名称"><el-input v-model="item.versionName" /></el-form-item>
<el-form-item label="版本码"><el-input-number v-model="item.versionCode" :min="1" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="item.forceUpdate" /></el-form-item>
<el-form-item label="更新说明"><el-input v-model="item.changeLog" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => item.file = f.raw ?? null"
:accept="item.platform === 'ANDROID' ? '.apk' : '.ipa'">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</template>
</div>
</div>
<el-divider content-position="left">发版配置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="定时发布">
<el-date-picker
v-model="unifiedReleaseOptions.scheduledPublishAt"
type="datetime"
placeholder="留空手动发布"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Webhook URL">
<el-input v-model="unifiedReleaseOptions.webhookUrl" placeholder="审核结果回调" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="自动提交市场">
<el-switch v-model="unifiedReleaseOptions.autoSubmitStore" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审核后自动发布">
<el-switch v-model="unifiedReleaseOptions.autoPublishAfterReview" />
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="unifiedReleaseOptions.autoSubmitStore" label="目标市场">
<el-checkbox-group v-model="unifiedReleaseOptions.storeTargets">
<el-checkbox v-for="s in enabledStores" :key="s.type" :value="s.type">{{ s.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-divider content-position="left">
Bundle 插件
<el-button link type="primary" @click="addUnifiedBundle" style="margin-left:8px">新增插件</el-button>
</el-divider>
<div v-for="(item, index) in unifiedBundleForms" :key="item.key" class="unified-bundle-row">
<div class="unified-bundle-head">
<div>插件 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeUnifiedBundle(index)" :disabled="unifiedBundleForms.length === 1">删除</el-button>
</div>
<div class="unified-grid">
<el-form-item label="模块ID"><el-input v-model="item.moduleId" /></el-form-item>
<el-form-item label="平台">
<el-select v-model="item.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="版本"><el-input v-model="item.version" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="item.minCommonVersion" /></el-form-item>
<el-form-item label="说明"><el-input v-model="item.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => item.file = f.raw ?? null" accept=".bundle,.js">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="showUnifiedUpload = false">取消</el-button>
<el-button type="primary" @click="submitUnifiedUpload" :loading="uploadingUnified">上传</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
updateAdminApi,
type AppVersion,
type RnBundle,
type StoreConfig,
type StoreType,
type StoreReviewState,
} from '@/api/update'
const route = useRoute()
const appId = route.params.appId as string
const activeTab = ref('app')
const appPlatform = ref<'ANDROID' | 'IOS'>('ANDROID')
const rnPlatform = ref<'ANDROID' | 'IOS' | ''>('')
const rnModuleFilter = ref('')
const appVersions = ref<AppVersion[]>([])
const loadingApp = ref(false)
const rnBundles = ref<RnBundle[]>([])
const loadingRn = ref(false)
const storeConfigs = ref<StoreConfig[]>([])
// ── Store definitions ──────────────────────────────────────────────────────
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
const STORE_DEFS: { type: StoreType; label: string; fields: FieldDef[] }[] = [
{
type: 'HUAWEI',
label: '华为应用市场',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password', placeholder: 'AppGallery Connect Client Secret' },
],
},
{
type: 'MI',
label: '小米应用商店',
fields: [
{ key: 'username', label: '用户名' },
{ key: 'privateKey', label: 'RSA 私钥', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
],
},
{
type: 'OPPO',
label: 'OPPO 软件商店',
fields: [
{ key: 'clientId', label: 'Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
},
{
type: 'VIVO',
label: 'vivo 应用商店',
fields: [
{ key: 'accessKey', label: 'Access Key' },
{ key: 'accessSecret', label: 'Access Secret', type: 'password' },
],
},
{
type: 'HONOR',
label: '荣耀应用市场',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
},
{
type: 'APP_STORE',
label: 'Apple App Store',
fields: [
{ key: 'teamId', label: 'Team ID' },
{ key: 'keyId', label: 'Key ID' },
{ key: 'privateKey', label: 'P8 私钥内容', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
{ key: 'bundleId', label: 'Bundle ID', placeholder: 'com.example.app' },
],
},
{
type: 'GOOGLE_PLAY',
label: 'Google Play',
fields: [
{ key: 'serviceAccountJson', label: '服务账号 JSON', type: 'textarea', placeholder: '{ "type": "service_account", ... }' },
],
},
]
// ── Store config helpers ──────────────────────────────────────────────────
function getStoreConfig(type: StoreType): StoreConfig | undefined {
return storeConfigs.value.find(c => c.storeType === type)
}
function isStoreEnabled(type: StoreType): boolean {
return getStoreConfig(type)?.enabled ?? false
}
const enabledStores = computed(() =>
STORE_DEFS.filter(s => isStoreEnabled(s.type))
)
async function toggleStore(type: StoreType, enabled: boolean) {
const cfg = getStoreConfig(type)
if (!cfg) return
try {
await updateAdminApi.saveStoreConfig(appId, type, cfg.configJson ?? '{}', enabled)
await loadStoreConfigs()
} catch { ElMessage.error('操作失败') }
}
async function loadStoreConfigs() {
try {
const res = await updateAdminApi.getStoreConfigs(appId)
storeConfigs.value = res.data.data
} catch { /* silent */ }
}
// ── Store config dialog ──────────────────────────────────────────────────
const showStoreConfig = ref(false)
const savingStoreConfig = ref(false)
const currentStoreDef = ref<typeof STORE_DEFS[0] | null>(null)
const storeConfigForm = ref<{ enabled: boolean; values: Record<string, string> }>({
enabled: true, values: {},
})
function openStoreConfigDialog(store: typeof STORE_DEFS[0]) {
currentStoreDef.value = store
const existing = getStoreConfig(store.type)
let values: Record<string, string> = {}
if (existing?.configJson) {
try { values = JSON.parse(existing.configJson) } catch { /* ignore */ }
}
storeConfigForm.value = { enabled: existing?.enabled ?? true, values }
showStoreConfig.value = true
}
async function saveStoreConfig() {
if (!currentStoreDef.value) return
savingStoreConfig.value = true
try {
await updateAdminApi.saveStoreConfig(
appId,
currentStoreDef.value.type,
JSON.stringify(storeConfigForm.value.values),
storeConfigForm.value.enabled,
)
ElMessage.success('凭据已保存')
showStoreConfig.value = false
await loadStoreConfigs()
} catch { ElMessage.error('保存失败') }
finally { savingStoreConfig.value = false }
}
async function removeStoreConfig(type: StoreType) {
await ElMessageBox.confirm('确认删除此应用商店凭据?', '提示', { type: 'warning' })
try {
await updateAdminApi.deleteStoreConfig(appId, type)
ElMessage.success('已删除')
await loadStoreConfigs()
} catch { ElMessage.error('删除失败') }
}
// ── Submit to stores ──────────────────────────────────────────────────────
const showSubmitStore = ref(false)
const submittingToStores = ref(false)
const submitStoreVersion = ref<AppVersion | null>(null)
const selectedStores = ref<StoreType[]>([])
function openSubmitStoreDialog(row: AppVersion) {
submitStoreVersion.value = row
selectedStores.value = enabledStores.value.map(s => s.type)
showSubmitStore.value = true
}
async function confirmSubmitToStores() {
if (!submitStoreVersion.value || !selectedStores.value.length) return
submittingToStores.value = true
try {
await updateAdminApi.executeSubmitToStores(submitStoreVersion.value.id, selectedStores.value)
ElMessage.success('已提交,服务端正在向应用市场上传,审核状态将通过 Webhook 或刷新页面查看')
showSubmitStore.value = false
loadAppVersions()
} catch { ElMessage.error('提交失败') }
finally { submittingToStores.value = false }
}
// ── Gray release ──────────────────────────────────────────────────────────
const showGray = ref(false)
const submittingGray = ref(false)
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
const grayForm = ref({ enabled: true, percent: 10 })
function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
grayTarget.value = { id: row.id, type }
grayForm.value = { enabled: true, percent: 10 }
showGray.value = true
}
async function submitGray() {
if (!grayTarget.value) return
submittingGray.value = true
try {
const { id, type } = grayTarget.value
if (type === 'app') {
await updateAdminApi.grayAppVersion(id, grayForm.value.enabled, grayForm.value.percent)
loadAppVersions()
} else {
await updateAdminApi.grayRnBundle(id, grayForm.value.enabled, grayForm.value.percent)
loadRnBundles()
}
ElMessage.success('灰度配置已保存')
showGray.value = false
} finally { submittingGray.value = false }
}
// ── Upload app ────────────────────────────────────────────────────────────
const showUploadApp = ref(false)
const uploadingApp = ref(false)
const appUploadForm = ref({
platform: 'ANDROID' as 'ANDROID' | 'IOS',
packageName: '',
versionName: '',
versionCode: 1,
forceUpdate: false,
changeLog: '',
file: null as File | null,
scheduledPublishAt: '',
webhookUrl: '',
autoSubmitStore: false,
storeTargets: [] as StoreType[],
autoPublishAfterReview: false,
})
async function submitAppUpload() {
const f = appUploadForm.value
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
uploadingApp.value = true
try {
const fd = new FormData()
fd.append('appId', appId)
fd.append('platform', f.platform)
fd.append('versionName', f.versionName)
fd.append('versionCode', String(f.versionCode))
fd.append('forceUpdate', String(f.forceUpdate))
if (f.packageName) fd.append('packageName', f.packageName)
if (f.changeLog) fd.append('changeLog', f.changeLog)
if (f.scheduledPublishAt) fd.append('scheduledPublishAt', f.scheduledPublishAt)
if (f.webhookUrl) fd.append('webhookUrl', f.webhookUrl)
if (f.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(f.storeTargets))
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
if (f.file) fd.append('apkFile', f.file)
const resp = await updateAdminApi.uploadAppVersion(fd)
if (f.autoSubmitStore && f.storeTargets.length) {
const versionId = resp.data.data.id
await updateAdminApi.executeSubmitToStores(versionId, f.storeTargets)
}
ElMessage.success('上传成功')
showUploadApp.value = false
loadAppVersions()
} finally { uploadingApp.value = false }
}
// ── Upload RN bundle ──────────────────────────────────────────────────────
const showUploadRn = ref(false)
const uploadingRn = ref(false)
const rnUploadForm = ref({
moduleId: '', platform: 'ANDROID' as 'ANDROID' | 'IOS',
version: '', minCommonVersion: '', note: '', file: null as File | null,
})
async function submitRnUpload() {
const f = rnUploadForm.value
if (!f.moduleId || !f.version || !f.file) return ElMessage.warning('请填写模块ID、版本和 Bundle 文件')
uploadingRn.value = true
try {
const fd = new FormData()
fd.append('appId', appId); fd.append('moduleId', f.moduleId)
fd.append('platform', f.platform); fd.append('version', f.version)
if (f.minCommonVersion) fd.append('minCommonVersion', f.minCommonVersion)
if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd)
ElMessage.success('Bundle 上传成功')
showUploadRn.value = false
loadRnBundles()
} finally { uploadingRn.value = false }
}
// ── Unified upload ────────────────────────────────────────────────────────
const showUnifiedUpload = ref(false)
const uploadingUnified = ref(false)
const unifiedAppForms = ref([
{ key: 'ANDROID', enabled: true, platform: 'ANDROID' as const, packageName: '', versionName: '', versionCode: 1, forceUpdate: false, changeLog: '', file: null as File | null },
{ key: 'IOS', enabled: true, platform: 'IOS' as const, packageName: '', versionName: '', versionCode: 1, forceUpdate: false, changeLog: '', file: null as File | null },
])
const unifiedBundleForms = ref([
{ key: 'bundle-0', moduleId: '', platform: 'ANDROID' as 'ANDROID' | 'IOS', version: '', minCommonVersion: '', note: '', file: null as File | null },
])
const unifiedReleaseOptions = ref({
scheduledPublishAt: '',
webhookUrl: '',
autoSubmitStore: false,
storeTargets: [] as StoreType[],
autoPublishAfterReview: false,
})
function addUnifiedBundle() {
unifiedBundleForms.value.push({ key: `bundle-${Date.now()}`, moduleId: '', platform: 'ANDROID', version: '', minCommonVersion: '', note: '', file: null })
}
function removeUnifiedBundle(index: number) {
unifiedBundleForms.value.splice(index, 1)
if (!unifiedBundleForms.value.length) addUnifiedBundle()
}
async function submitUnifiedUpload() {
const appItems = unifiedAppForms.value
.filter(i => i.enabled && i.file)
.map(i => ({ fileKey: i.key, platform: i.platform, versionName: i.versionName, versionCode: i.versionCode, changeLog: i.changeLog || undefined, forceUpdate: i.forceUpdate, packageName: i.packageName || undefined }))
const bundleItems = unifiedBundleForms.value
.filter(i => i.file)
.map(i => ({ fileKey: i.key, moduleId: i.moduleId, platform: i.platform, version: i.version, minCommonVersion: i.minCommonVersion || undefined, note: i.note || undefined }))
if (!appItems.length && !bundleItems.length) return ElMessage.warning('请至少选择一个包或 Bundle 文件')
for (const i of unifiedAppForms.value) {
if (i.enabled && i.file && (!i.versionName || !i.versionCode)) return ElMessage.warning('请填写整包版本信息')
}
const opts = unifiedReleaseOptions.value
uploadingUnified.value = true
try {
const fd = new FormData()
fd.append('appId', appId)
fd.append('manifest', JSON.stringify({ appVersions: appItems, rnBundles: bundleItems }))
if (opts.scheduledPublishAt) fd.append('scheduledPublishAt', opts.scheduledPublishAt)
if (opts.webhookUrl) fd.append('webhookUrl', opts.webhookUrl)
if (opts.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(opts.storeTargets))
fd.append('autoPublishAfterReview', String(opts.autoPublishAfterReview))
for (const i of unifiedAppForms.value) if (i.enabled && i.file) fd.append(i.key, i.file)
for (const i of unifiedBundleForms.value) if (i.file) fd.append(i.key, i.file)
const resp = await updateAdminApi.uploadUnifiedRelease(fd)
// If auto-submit to stores, trigger for each uploaded app version
if (opts.autoSubmitStore && opts.storeTargets.length) {
const versionIds: string[] = (resp.data as any)?.data?.appVersionIds ?? []
for (const vid of versionIds) {
await updateAdminApi.executeSubmitToStores(vid, opts.storeTargets).catch(() => {})
}
}
ElMessage.success('一键上传成功')
showUnifiedUpload.value = false
loadAppVersions(); loadRnBundles()
} finally { uploadingUnified.value = false }
}
// ── Version list actions ──────────────────────────────────────────────────
async function loadAppVersions() {
loadingApp.value = true
try {
const res = await updateAdminApi.listAppVersions(appId, appPlatform.value)
appVersions.value = res.data.data
} catch { ElMessage.error('加载失败') }
finally { loadingApp.value = false }
}
async function loadRnBundles() {
loadingRn.value = true
try {
const res = await updateAdminApi.listRnBundles(appId, rnModuleFilter.value || undefined, rnPlatform.value || undefined)
rnBundles.value = res.data.data
} catch { ElMessage.error('加载失败') }
finally { loadingRn.value = false }
}
async function publishApp(id: string) { await updateAdminApi.publishAppVersion(id); ElMessage.success('已发布'); loadAppVersions() }
async function unpublishApp(id: string) { await updateAdminApi.unpublishAppVersion(id); ElMessage.success('已下架'); loadAppVersions() }
async function publishRn(id: string) { await updateAdminApi.publishRnBundle(id); ElMessage.success('已发布'); loadRnBundles() }
async function unpublishRn(id: string) { await updateAdminApi.unpublishRnBundle(id); ElMessage.success('已下架'); loadRnBundles() }
// ── Formatting helpers ─────────────────────────────────────────────────────
function formatTime(t: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function statusLabel(row: { publishStatus: string }) { return { DRAFT: '草稿', PUBLISHED: '已发布', DEPRECATED: '已下架' }[row.publishStatus] ?? row.publishStatus }
function statusTagType(row: { publishStatus: string }) { return { DRAFT: '', PUBLISHED: 'success', DEPRECATED: 'info' }[row.publishStatus] ?? '' }
function storeLabel(type: string) { return STORE_DEFS.find(s => s.type === type)?.label ?? type }
function reviewLabel(state: string): string { return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state }
function reviewTagType(state: string): string { return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? '' }
function parseStoreReview(json?: string): { store: string; state: string }[] {
if (!json) return []
try {
const m = JSON.parse(json) as Record<string, string>
return Object.entries(m).map(([store, state]) => ({ store, state }))
} catch { return [] }
}
onMounted(() => {
loadAppVersions()
loadRnBundles()
loadStoreConfigs()
})
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.text-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-left: 8px; }
/* Store config grid */
.store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.store-card { cursor: default; }
.store-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.store-card-name { font-weight: 600; font-size: 14px; }
.store-card-status { margin-bottom: 12px; }
.store-card-footer { display: flex; gap: 8px; }
/* Submit store checkbox */
.store-checkbox-row { padding: 6px 0; }
/* Unified upload */
.unified-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.unified-block, .unified-bundle-row {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 12px;
background: var(--el-bg-color-page);
}
.unified-block-title, .unified-bundle-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.unified-bundle-row + .unified-bundle-row { margin-top: 12px; }
</style>