889 行
38 KiB
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>
|