2026-04-24 16:16:54 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<el-page-header @back="$router.back()" :content="`版本管理 — ${appId}`" style="margin-bottom:20px" />
|
|
|
|
|
|
|
|
|
|
|
|
<el-card>
|
|
|
|
|
|
<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>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-table-column prop="versionName" label="版本名" width="110" />
|
|
|
|
|
|
<el-table-column prop="versionCode" label="版本码" width="90" />
|
|
|
|
|
|
<el-table-column label="状态" width="140">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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 />
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-table-column prop="createdAt" label="上传时间" width="160">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
|
|
|
|
|
</el-table-column>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-table-column label="操作" width="220" fixed="right">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
|
|
|
|
|
|
link type="primary" size="small"
|
|
|
|
|
|
@click="openSubmitStoreDialog(row)">提交市场</el-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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 />
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-table-column prop="createdAt" label="上传时间" width="160">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
|
|
|
|
|
</el-table-column>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-table-column label="操作" width="160" fixed="right">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<template #default="{row}">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</el-tab-pane>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- App Store Config -->
|
|
|
|
|
|
<el-tab-pane label="应用商店配置" name="store">
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-alert
|
|
|
|
|
|
title="应用商店配置按渠道分别维护,页面只保留真实可用的凭据字段和提交说明。"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
style="margin-bottom:16px"
|
|
|
|
|
|
/>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<div class="store-card-meta">
|
|
|
|
|
|
<div v-if="getStoreConfig(store.type)">更新于 {{ formatTime(getStoreConfig(store.type)?.updatedAt ?? '') }}</div>
|
|
|
|
|
|
<div v-else>请先补齐 {{ store.shortLabel }} 的凭据</div>
|
|
|
|
|
|
</div>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</el-tabs>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Gray Release Dialog -->
|
|
|
|
|
|
<el-dialog v-model="showGray" title="灰度发布配置" width="400px">
|
|
|
|
|
|
<el-form label-width="90px">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<!-- 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} 凭据`"
|
2026-04-29 12:33:26 +08:00
|
|
|
|
width="980px"
|
2026-04-29 00:36:41 +08:00
|
|
|
|
>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<div v-if="currentStoreDef" class="store-config-layout">
|
|
|
|
|
|
<el-form :model="storeConfigForm" label-width="160px" class="store-config-form">
|
|
|
|
|
|
<el-form-item label="启用">
|
|
|
|
|
|
<el-switch v-model="storeConfigForm.enabled" />
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<div class="store-config-guide">
|
|
|
|
|
|
<div class="store-config-guide-title">填写指引</div>
|
|
|
|
|
|
<div class="store-config-guide-subtitle">{{ currentStoreDef.guideSubtitle }}</div>
|
|
|
|
|
|
<el-steps direction="vertical" :active="currentStoreDef.guideSteps.length" finish-status="success" style="margin:16px 0">
|
|
|
|
|
|
<el-step v-for="step in currentStoreDef.guideSteps" :key="step.title" :title="step.title" :description="step.description" />
|
|
|
|
|
|
</el-steps>
|
|
|
|
|
|
<el-link :href="currentStoreDef.guideUrl" target="_blank" type="primary">查看官方文档</el-link>
|
|
|
|
|
|
<el-divider />
|
|
|
|
|
|
<div class="store-config-guide-hint">{{ currentStoreDef.guideHint }}</div>
|
|
|
|
|
|
<img
|
|
|
|
|
|
v-if="currentStoreDef.guideImage"
|
|
|
|
|
|
:src="currentStoreDef.guideImage"
|
|
|
|
|
|
:alt="currentStoreDef.label + ' 配置截图'"
|
|
|
|
|
|
class="store-config-guide-image"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="showStoreConfig = false">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" @click="saveStoreConfig" :loading="savingStoreConfig">保存</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<!-- Upload App Version Dialog -->
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-dialog v-model="showUploadApp" title="上传 App 版本" width="540px">
|
|
|
|
|
|
<el-form :model="appUploadForm" label-width="120px">
|
|
|
|
|
|
<el-divider content-position="left">基础信息</el-divider>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-form-item label="包名 / Bundle ID">
|
|
|
|
|
|
<el-input v-model="appUploadForm.packageName" placeholder="选择文件后可自动填充" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" placeholder="选择文件后可自动填充" /></el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-form-item label="包文件">
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk,.ipa">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-button>选择文件</el-button>
|
|
|
|
|
|
</el-upload>
|
|
|
|
|
|
</el-form-item>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-alert
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
title="选中 APK 后会自动读取包名、版本名和版本码;iOS 包若能解析到 Info.plist,也会自动填充。"
|
|
|
|
|
|
/>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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="审核后自动发布">
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-switch v-model="appUploadForm.autoPublishAfterReview" :disabled="!!appUploadForm.scheduledPublishAt" />
|
|
|
|
|
|
<span class="form-tip">与定时发布互斥</span>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-form :model="rnUploadForm" label-width="120px">
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-form-item label="Bundle 文件">
|
|
|
|
|
|
<el-upload :auto-upload="false" :limit="1" :on-change="onRnBundleChange" accept=".bundle,.js">
|
|
|
|
|
|
<el-button>选择文件</el-button>
|
|
|
|
|
|
</el-upload>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
title="推荐文件名格式:moduleId__ANDROID__1.0.0__1.0.0.bundle,系统会按命名自动识别模块、平台、版本和最低 Common 版本。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" placeholder="可由文件名自动识别" /></el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="可由文件名自动识别" /></el-form-item>
|
|
|
|
|
|
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="可由文件名自动识别" /></el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></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>
|
2026-04-28 21:05:07 +08:00
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-04-29 12:33:26 +08:00
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
2026-04-24 16:16:54 +08:00
|
|
|
|
import { useRoute } from 'vue-router'
|
2026-04-29 00:36:41 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import {
|
|
|
|
|
|
updateAdminApi,
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type AppPackageInspectResult,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type AppVersion,
|
|
|
|
|
|
type RnBundle,
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type RnBundleInspectResult,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type StoreConfig,
|
|
|
|
|
|
type StoreType,
|
|
|
|
|
|
} from '@/api/update'
|
2026-04-29 12:33:26 +08:00
|
|
|
|
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
|
|
|
|
|
|
import miGuideImage from '@/assets/update-store/mi/01.png'
|
|
|
|
|
|
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
|
|
|
|
|
|
import vivoGuideImage from '@/assets/update-store/vivo/01.png'
|
|
|
|
|
|
import honorGuideImage from '@/assets/update-store/honor/01.png'
|
2026-04-24 16:16:54 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const appId = route.params.appId as string
|
|
|
|
|
|
|
|
|
|
|
|
const activeTab = ref('app')
|
|
|
|
|
|
const appPlatform = ref<'ANDROID' | 'IOS'>('ANDROID')
|
2026-04-28 21:05:07 +08:00
|
|
|
|
const rnPlatform = ref<'ANDROID' | 'IOS' | ''>('')
|
2026-04-24 16:16:54 +08:00
|
|
|
|
const rnModuleFilter = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
const appVersions = ref<AppVersion[]>([])
|
|
|
|
|
|
const loadingApp = ref(false)
|
|
|
|
|
|
const rnBundles = ref<RnBundle[]>([])
|
|
|
|
|
|
const loadingRn = ref(false)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const storeConfigs = ref<StoreConfig[]>([])
|
2026-04-24 16:16:54 +08:00
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type GuideStep = { title: string; description: string }
|
|
|
|
|
|
type StoreDef = {
|
|
|
|
|
|
type: StoreType
|
|
|
|
|
|
label: string
|
|
|
|
|
|
shortLabel: string
|
|
|
|
|
|
fields: FieldDef[]
|
|
|
|
|
|
guideSubtitle: string
|
|
|
|
|
|
guideUrl: string
|
|
|
|
|
|
guideSteps: GuideStep[]
|
|
|
|
|
|
guideHint: string
|
|
|
|
|
|
guideImage?: string
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
const STORE_DEFS: StoreDef[] = [
|
2026-04-28 21:05:07 +08:00
|
|
|
|
{
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type: 'HUAWEI',
|
|
|
|
|
|
label: '华为应用市场',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: '华为',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'clientSecret', label: 'Client Secret', type: 'password', placeholder: 'AppGallery Connect Client Secret' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在 AppGallery Connect 创建 Connect API 凭据',
|
|
|
|
|
|
guideUrl: 'https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '创建应用或进入应用管理', description: '在 AppGallery Connect 中找到目标应用。' },
|
|
|
|
|
|
{ title: '进入开发工具 / Connect API', description: '创建服务端凭据并选择 APP 管理员角色。' },
|
|
|
|
|
|
{ title: '复制客户端 ID 和密钥', description: '把 Client ID / Client Secret 填到这里。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '与后端提交服务对应字段一致:clientId、clientSecret。',
|
|
|
|
|
|
guideImage: huaweiGuideImage,
|
2026-04-28 21:05:07 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type: 'MI',
|
|
|
|
|
|
label: '小米应用商店',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: '小米',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'username', label: '用户名' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'privateKey', label: 'RSA 私钥', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在自动发布接口页面获取上传所需密钥',
|
|
|
|
|
|
guideUrl: 'https://dev.mi.com/distribute/doc/details?pId=1134',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '进入应用游戏管理', description: '在控制台选择目标应用。' },
|
|
|
|
|
|
{ title: '打开自动发布接口', description: '下载公钥文件并准备私钥。' },
|
|
|
|
|
|
{ title: '录入用户名和私钥', description: '这里保存的是服务端上传所需凭据。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '当前字段保持为 username / privateKey,与后端服务一致。',
|
|
|
|
|
|
guideImage: miGuideImage,
|
2026-04-28 21:05:07 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type: 'OPPO',
|
|
|
|
|
|
label: 'OPPO 软件商店',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: 'OPPO',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'clientId', label: 'Client ID' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在我的 API 里创建服务端应用',
|
|
|
|
|
|
guideUrl: 'https://open.oppomobile.com/new/developmentDoc/info?id=11119',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '进入“我的 API”', description: '确认当前应用拥有服务端应用能力。' },
|
|
|
|
|
|
{ title: '新建服务端应用', description: '按平台要求创建接口凭据。' },
|
|
|
|
|
|
{ title: '保存 client_id / client_secret', description: '这两个值对应这里的 Client ID / Client Secret。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '字段与后端 submitToOppo 读取逻辑一致。',
|
|
|
|
|
|
guideImage: oppoGuideImage,
|
2026-04-28 21:05:07 +08:00
|
|
|
|
},
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{
|
|
|
|
|
|
type: 'VIVO',
|
|
|
|
|
|
label: 'vivo 应用商店',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: 'vivo',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'accessKey', label: 'Access Key' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'accessSecret', label: 'Access Secret', type: 'password' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在 API 管理中复制 access_key / access_secret',
|
|
|
|
|
|
guideUrl: 'https://dev.vivo.com.cn/documentCenter/doc/326',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '进入 api 管理', description: '找到当前应用对应的接口管理入口。' },
|
|
|
|
|
|
{ title: '激活后再读取密钥', description: '首次启用后可能需要刷新页面。' },
|
|
|
|
|
|
{ title: '填入 Access Key / Access Secret', description: '与后端提交服务字段保持一致。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '提交服务读取 accessKey / accessSecret。',
|
|
|
|
|
|
guideImage: vivoGuideImage,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'HONOR',
|
|
|
|
|
|
label: '荣耀应用市场',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: '荣耀',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在管理中心申请凭证并复制 Client_id / 密钥',
|
|
|
|
|
|
guideUrl: 'https://developer.honor.com/cn',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '进入管理中心', description: '打开荣耀开发者后台并进入凭证页。' },
|
|
|
|
|
|
{ title: '申请凭证', description: '创建用于服务端上传的 API 凭据。' },
|
|
|
|
|
|
{ title: '保存 Client_id / 密钥', description: '与这里的 Client ID / Client Secret 对应。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '与后端 Honor 提交流程完全一致。',
|
|
|
|
|
|
guideImage: honorGuideImage,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'APP_STORE',
|
|
|
|
|
|
label: 'Apple App Store',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: 'App Store',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'teamId', label: 'Team ID' },
|
|
|
|
|
|
{ key: 'keyId', label: 'Key ID' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'privateKey', label: 'P8 私钥内容', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
|
2026-04-29 12:33:26 +08:00
|
|
|
|
{ key: 'bundleId', label: 'Bundle ID', placeholder: 'com.example.app' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '在 App Store Connect 创建 API Key',
|
|
|
|
|
|
guideUrl: 'https://developer.apple.com/documentation/appstoreconnectapi',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '进入 App Store Connect', description: '打开用户与访问或对应的 App 详情。' },
|
|
|
|
|
|
{ title: '生成 API Key', description: '保存 Team ID、Key ID 和 p8 私钥。' },
|
|
|
|
|
|
{ title: '补充 Bundle ID', description: '这里填写目标包名或 bundle identifier。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: 'App Store 提交与后端 Apple 提交流程读取的字段一致。',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'GOOGLE_PLAY',
|
|
|
|
|
|
label: 'Google Play',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
shortLabel: 'Google Play',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
fields: [
|
|
|
|
|
|
{ key: 'serviceAccountJson', label: '服务账号 JSON', type: 'textarea', placeholder: '{ "type": "service_account", ... }' },
|
|
|
|
|
|
],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSubtitle: '使用服务账号 JSON 授权上传',
|
|
|
|
|
|
guideUrl: 'https://developer.android.com/google/play/developer-api',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '创建服务账号', description: '从 Google Cloud 获取服务账号 JSON。' },
|
|
|
|
|
|
{ title: '授予 Play 管理权限', description: '把该服务账号绑定到目标应用。' },
|
|
|
|
|
|
{ title: '粘贴 JSON 内容', description: '这里保存的是上传服务端所需内容。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideHint: '目前字段以 JSON 方式保存,方便服务端直接读取。',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function getStoreConfig(type: StoreType): StoreConfig | undefined {
|
|
|
|
|
|
return storeConfigs.value.find(c => c.storeType === type)
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
function isStoreEnabled(type: StoreType): boolean {
|
|
|
|
|
|
return getStoreConfig(type)?.enabled ?? false
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
const enabledStores = computed(() => STORE_DEFS.filter(s => isStoreEnabled(s.type)))
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('操作失败')
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
async function loadStoreConfigs() {
|
2026-04-24 16:16:54 +08:00
|
|
|
|
try {
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const res = await updateAdminApi.getStoreConfigs(appId)
|
|
|
|
|
|
storeConfigs.value = res.data.data
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
storeConfigs.value = []
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const showStoreConfig = ref(false)
|
|
|
|
|
|
const savingStoreConfig = ref(false)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
const currentStoreDef = ref<StoreDef | null>(null)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const storeConfigForm = ref<{ enabled: boolean; values: Record<string, string> }>({
|
2026-04-29 12:33:26 +08:00
|
|
|
|
enabled: true,
|
|
|
|
|
|
values: {},
|
2026-04-29 00:36:41 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
function openStoreConfigDialog(store: StoreDef) {
|
2026-04-29 00:36:41 +08:00
|
|
|
|
currentStoreDef.value = store
|
|
|
|
|
|
const existing = getStoreConfig(store.type)
|
|
|
|
|
|
let values: Record<string, string> = {}
|
|
|
|
|
|
if (existing?.configJson) {
|
2026-04-29 12:33:26 +08:00
|
|
|
|
try {
|
|
|
|
|
|
values = JSON.parse(existing.configJson)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
values = {}
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
storeConfigForm.value = { enabled: existing?.enabled ?? true, values }
|
|
|
|
|
|
showStoreConfig.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveStoreConfig() {
|
|
|
|
|
|
if (!currentStoreDef.value) return
|
|
|
|
|
|
savingStoreConfig.value = true
|
2026-04-24 16:16:54 +08:00
|
|
|
|
try {
|
2026-04-29 00:36:41 +08:00
|
|
|
|
await updateAdminApi.saveStoreConfig(
|
2026-04-24 16:16:54 +08:00
|
|
|
|
appId,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
currentStoreDef.value.type,
|
|
|
|
|
|
JSON.stringify(storeConfigForm.value.values),
|
|
|
|
|
|
storeConfigForm.value.enabled,
|
2026-04-24 16:16:54 +08:00
|
|
|
|
)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
ElMessage.success('凭据已保存')
|
|
|
|
|
|
showStoreConfig.value = false
|
|
|
|
|
|
await loadStoreConfigs()
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('保存失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingStoreConfig.value = false
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
async function removeStoreConfig(type: StoreType) {
|
|
|
|
|
|
await ElMessageBox.confirm('确认删除此应用商店凭据?', '提示', { type: 'warning' })
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateAdminApi.deleteStoreConfig(appId, type)
|
|
|
|
|
|
ElMessage.success('已删除')
|
|
|
|
|
|
await loadStoreConfigs()
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('删除失败')
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('提交失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submittingToStores.value = false
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
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)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadAppVersions()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await updateAdminApi.grayRnBundle(id, grayForm.value.enabled, grayForm.value.percent)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadRnBundles()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
ElMessage.success('灰度配置已保存')
|
|
|
|
|
|
showGray.value = false
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
submittingGray.value = false
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
async function onAppPackageChange(uploadFile: { raw?: File } | null) {
|
|
|
|
|
|
const file = uploadFile?.raw ?? null
|
|
|
|
|
|
appUploadForm.value.file = file
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('apkFile', file)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await updateAdminApi.inspectAppPackage(formData)
|
|
|
|
|
|
const inspected = res.data.data as AppPackageInspectResult
|
|
|
|
|
|
if (inspected.platform) appUploadForm.value.platform = inspected.platform
|
|
|
|
|
|
if (inspected.packageName) appUploadForm.value.packageName = inspected.packageName
|
|
|
|
|
|
if (inspected.versionName) appUploadForm.value.versionName = inspected.versionName
|
|
|
|
|
|
if (typeof inspected.versionCode === 'number') appUploadForm.value.versionCode = inspected.versionCode
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.warning('已选择文件,但未能完整识别,请补全版本信息后上传')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
async function submitAppUpload() {
|
|
|
|
|
|
const f = appUploadForm.value
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (!f.file) return ElMessage.warning('请先选择应用包文件')
|
2026-04-24 16:16:54 +08:00
|
|
|
|
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (f.scheduledPublishAt && f.autoPublishAfterReview) {
|
|
|
|
|
|
f.autoPublishAfterReview = false
|
|
|
|
|
|
ElMessage.warning('定时发布和审核后自动发布互斥,已按定时发布处理')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
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))
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (f.packageName) fd.append('packageName', f.packageName)
|
|
|
|
|
|
if (f.changeLog) fd.append('changeLog', f.changeLog)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
if (f.scheduledPublishAt) fd.append('scheduledPublishAt', f.scheduledPublishAt)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (f.webhookUrl) fd.append('webhookUrl', f.webhookUrl)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
if (f.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(f.storeTargets))
|
|
|
|
|
|
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
|
2026-04-29 12:33:26 +08:00
|
|
|
|
fd.append('apkFile', f.file)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const resp = await updateAdminApi.uploadAppVersion(fd)
|
|
|
|
|
|
if (f.autoSubmitStore && f.storeTargets.length) {
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await updateAdminApi.executeSubmitToStores(resp.data.data.id, f.storeTargets)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
ElMessage.success('上传成功')
|
2026-04-24 16:16:54 +08:00
|
|
|
|
showUploadApp.value = false
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
uploadingApp.value = false
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
const showUploadRn = ref(false)
|
|
|
|
|
|
const uploadingRn = ref(false)
|
|
|
|
|
|
const rnUploadForm = ref({
|
2026-04-29 12:33:26 +08:00
|
|
|
|
moduleId: '',
|
|
|
|
|
|
platform: 'ANDROID' as 'ANDROID' | 'IOS',
|
|
|
|
|
|
version: '',
|
|
|
|
|
|
minCommonVersion: '',
|
|
|
|
|
|
note: '',
|
|
|
|
|
|
file: null as File | null,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
function parseRnBundleName(fileName: string): RnBundleInspectResult | null {
|
|
|
|
|
|
const baseName = fileName.replace(/\.[^.]+$/, '')
|
|
|
|
|
|
const parts = baseName.split('__')
|
|
|
|
|
|
if (parts.length >= 4) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
moduleId: parts[0],
|
|
|
|
|
|
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS',
|
|
|
|
|
|
version: parts[2],
|
|
|
|
|
|
minCommonVersion: parts[3],
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
detected: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onRnBundleChange(uploadFile: { raw?: File } | null) {
|
|
|
|
|
|
const file = uploadFile?.raw ?? null
|
|
|
|
|
|
rnUploadForm.value.file = file
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
|
|
|
|
|
|
const local = parseRnBundleName(file.name)
|
|
|
|
|
|
if (local) {
|
|
|
|
|
|
if (local.moduleId) rnUploadForm.value.moduleId = local.moduleId
|
|
|
|
|
|
if (local.platform) rnUploadForm.value.platform = local.platform
|
|
|
|
|
|
if (local.version) rnUploadForm.value.version = local.version
|
|
|
|
|
|
if (local.minCommonVersion) rnUploadForm.value.minCommonVersion = local.minCommonVersion
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('bundle', file)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await updateAdminApi.inspectRnBundle(formData)
|
|
|
|
|
|
const inspected = res.data.data as RnBundleInspectResult
|
|
|
|
|
|
if (inspected.moduleId) rnUploadForm.value.moduleId = inspected.moduleId
|
|
|
|
|
|
if (inspected.platform) rnUploadForm.value.platform = inspected.platform
|
|
|
|
|
|
if (inspected.version) rnUploadForm.value.version = inspected.version
|
|
|
|
|
|
if (inspected.minCommonVersion) rnUploadForm.value.minCommonVersion = inspected.minCommonVersion
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
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()
|
2026-04-29 12:33:26 +08:00
|
|
|
|
fd.append('appId', appId)
|
|
|
|
|
|
fd.append('moduleId', f.moduleId)
|
|
|
|
|
|
fd.append('platform', f.platform)
|
|
|
|
|
|
fd.append('version', f.version)
|
2026-04-24 16:16:54 +08:00
|
|
|
|
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)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
ElMessage.success('Bundle 上传成功')
|
2026-04-24 16:16:54 +08:00
|
|
|
|
showUploadRn.value = false
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadRnBundles()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
uploadingRn.value = false
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadAppVersions() {
|
|
|
|
|
|
loadingApp.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await updateAdminApi.listAppVersions(appId, appPlatform.value)
|
|
|
|
|
|
appVersions.value = res.data.data
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('加载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingApp.value = false
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRnBundles() {
|
|
|
|
|
|
loadingRn.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await updateAdminApi.listRnBundles(appId, rnModuleFilter.value || undefined, rnPlatform.value || undefined)
|
|
|
|
|
|
rnBundles.value = res.data.data
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('加载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingRn.value = false
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
async function publishApp(id: string) {
|
|
|
|
|
|
await updateAdminApi.publishAppVersion(id)
|
|
|
|
|
|
ElMessage.success('已发布')
|
|
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
async function unpublishApp(id: string) {
|
|
|
|
|
|
await updateAdminApi.unpublishAppVersion(id)
|
|
|
|
|
|
ElMessage.success('已下架')
|
|
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
async function publishRn(id: string) {
|
|
|
|
|
|
await updateAdminApi.publishRnBundle(id)
|
|
|
|
|
|
ElMessage.success('已发布')
|
|
|
|
|
|
await loadRnBundles()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function unpublishRn(id: string) {
|
|
|
|
|
|
await updateAdminApi.unpublishRnBundle(id)
|
|
|
|
|
|
ElMessage.success('已下架')
|
|
|
|
|
|
await loadRnBundles()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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] ?? ''
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
|
|
|
|
|
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 }))
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
loadAppVersions()
|
|
|
|
|
|
loadRnBundles()
|
2026-04-29 00:36:41 +08:00
|
|
|
|
loadStoreConfigs()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
2026-04-29 00:36:41 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
/* Store config dialog */
|
|
|
|
|
|
.store-config-layout {
|
2026-04-28 21:05:07 +08:00
|
|
|
|
display: grid;
|
2026-04-29 12:33:26 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.store-config-form {
|
|
|
|
|
|
min-width: 0;
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
.store-config-guide {
|
2026-04-28 21:05:07 +08:00
|
|
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
|
border-radius: 8px;
|
2026-04-29 12:33:26 +08:00
|
|
|
|
padding: 16px;
|
|
|
|
|
|
background: var(--el-fill-color-blank);
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
.store-config-guide-title {
|
2026-04-28 21:05:07 +08:00
|
|
|
|
font-weight: 600;
|
2026-04-29 12:33:26 +08:00
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.store-config-guide-subtitle {
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.store-config-guide-hint {
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
.store-config-guide-image {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 320px;
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
object-fit: cover;
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</style>
|