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

1023 行
40 KiB
Vue

<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-button value="HARMONY">Harmony</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="showUploadApp = true">上传新版本</el-button>
<el-button @click="goToStoreGuide">应用配置指引</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">
<el-alert
title="应用商店配置按渠道分别维护,页面只保留真实可用的凭据字段和提交说明。"
type="info"
show-icon
:closable="false"
style="margin-bottom:16px"
/>
<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-meta">
<div v-if="getStoreConfig(store.type)">更新于 {{ formatTime(getStoreConfig(store.type)?.updatedAt ?? '') }}</div>
<div v-else>请先补齐 {{ store.shortLabel }} 的凭据</div>
</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="980px"
>
<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" />
</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>
<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>
<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-option value="HARMONY" label="Harmony" />
</el-select>
</el-form-item>
<el-form-item label="包名 / Bundle ID">
<el-input v-model="appUploadForm.packageName" placeholder="选择文件后可自动填充,或手动填写 Harmony bundleName" />
</el-form-item>
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" placeholder="选择文件后可自动填充" /></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 v-if="appUploadForm.platform !== 'HARMONY'" label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk,.ipa">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-alert
v-if="appUploadForm.platform === 'HARMONY'"
type="warning"
:closable="false"
show-icon
title="Harmony 应用更新只提交市场配置与版本信息,不需要本地安装包。"
/>
<el-alert
v-if="appUploadForm.platform !== 'HARMONY'"
type="info"
:closable="false"
show-icon
title="选中 APK / IPA 后会自动读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。"
/>
<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.autoPublishAfterReview" :disabled="!!appUploadForm.scheduledPublishAt" />
<span class="form-tip">与定时发布互斥</span>
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'IOS'" label="App Store 链接">
<el-input v-model="appUploadForm.appStoreUrl" placeholder="可填写 App Store 链接,便于审核跳转" />
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'HARMONY'" label="应用市场链接">
<el-input v-model="appUploadForm.marketUrl" placeholder="Harmony 应用市场详情页链接" />
</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="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__com.example.app.bundle,系统会按命名自动识别模块、平台、版本、最低 Common 版本和包名。"
/>
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" placeholder="可由文件名自动识别" /></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-option value="HARMONY" label="Harmony" />
</el-select>
</el-form-item>
<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>
<el-form-item label="包名 / Bundle">
<el-input v-model="rnUploadForm.packageName" placeholder="可选,建议与应用包名一致" />
</el-form-item>
<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>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
updateAdminApi,
type AppPackageInspectResult,
type AppVersion,
type RnBundle,
type RnBundleInspectResult,
type StoreConfig,
type StoreType,
} from '@/api/update'
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'
const route = useRoute()
const router = useRouter()
const appId = route.params.appId as string
const activeTab = ref('app')
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const rnPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY' | ''>('')
const rnModuleFilter = ref('')
const appVersions = ref<AppVersion[]>([])
const loadingApp = ref(false)
const rnBundles = ref<RnBundle[]>([])
const loadingRn = ref(false)
const storeConfigs = ref<StoreConfig[]>([])
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
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
}
const STORE_DEFS: StoreDef[] = [
{
type: 'HUAWEI',
label: '华为应用市场',
shortLabel: '华为',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password', placeholder: 'AppGallery Connect Client Secret' },
],
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,
},
{
type: 'MI',
label: '小米应用商店',
shortLabel: '小米',
fields: [
{ key: 'username', label: '用户名' },
{ key: 'privateKey', label: 'RSA 私钥', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
],
guideSubtitle: '在自动发布接口页面获取上传所需密钥',
guideUrl: 'https://dev.mi.com/distribute/doc/details?pId=1134',
guideSteps: [
{ title: '进入应用游戏管理', description: '在控制台选择目标应用。' },
{ title: '打开自动发布接口', description: '下载公钥文件并准备私钥。' },
{ title: '录入用户名和私钥', description: '这里保存的是服务端上传所需凭据。' },
],
guideHint: '当前字段保持为 username / privateKey,与后端服务一致。',
guideImage: miGuideImage,
},
{
type: 'OPPO',
label: 'OPPO 软件商店',
shortLabel: 'OPPO',
fields: [
{ key: 'clientId', label: 'Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
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,
},
{
type: 'VIVO',
label: 'vivo 应用商店',
shortLabel: 'vivo',
fields: [
{ key: 'accessKey', label: 'Access Key' },
{ key: 'accessSecret', label: 'Access Secret', type: 'password' },
],
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,
},
{
type: 'HONOR',
label: '荣耀应用市场',
shortLabel: '荣耀',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
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,
},
{
type: 'APP_STORE',
label: 'Apple App Store',
shortLabel: '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' },
],
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 提交流程读取的字段一致。',
},
{
type: 'GOOGLE_PLAY',
label: 'Google Play',
shortLabel: 'Google Play',
fields: [
{ key: 'serviceAccountJson', label: '服务账号 JSON', type: 'textarea', placeholder: '{ "type": "service_account", ... }' },
],
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 方式保存,方便服务端直接读取。',
},
]
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 {
storeConfigs.value = []
}
}
const showStoreConfig = ref(false)
const savingStoreConfig = ref(false)
const currentStoreDef = ref<StoreDef | null>(null)
const storeConfigForm = ref<{ enabled: boolean; values: Record<string, string> }>({
enabled: true,
values: {},
})
function openStoreConfigDialog(store: StoreDef) {
currentStoreDef.value = store
const existing = getStoreConfig(store.type)
let values: Record<string, string> = {}
if (existing?.configJson) {
try {
values = JSON.parse(existing.configJson)
} catch {
values = {}
}
}
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('删除失败')
}
}
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
await loadAppVersions()
} catch {
ElMessage.error('提交失败')
} finally {
submittingToStores.value = false
}
}
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)
await loadAppVersions()
} else {
await updateAdminApi.grayRnBundle(id, grayForm.value.enabled, grayForm.value.percent)
await loadRnBundles()
}
ElMessage.success('灰度配置已保存')
showGray.value = false
} finally {
submittingGray.value = false
}
}
const showUploadApp = ref(false)
const uploadingApp = ref(false)
const appUploadForm = ref({
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
packageName: '',
versionName: '',
versionCode: 1,
forceUpdate: false,
changeLog: '',
file: null as File | null,
scheduledPublishAt: '',
webhookUrl: '',
autoPublishAfterReview: false,
appStoreUrl: '',
marketUrl: '',
})
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 && appUploadForm.value.packageName !== inspected.packageName) {
try {
await ElMessageBox.confirm(
`检测到文件包名为 ${inspected.packageName},当前填写为 ${appUploadForm.value.packageName}。是否强制使用文件识别结果?`,
'包名不一致',
{ type: 'warning', confirmButtonText: '使用文件包名', cancelButtonText: '保留当前填写' },
)
appUploadForm.value.packageName = inspected.packageName
} catch {
// keep current manual input
}
} else 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('已选择文件,但未能完整识别,请补全版本信息后上传')
}
}
async function submitAppUpload() {
const f = appUploadForm.value
if (f.platform !== 'HARMONY' && !f.file) return ElMessage.warning('请先选择应用包文件')
if (f.platform === 'HARMONY' && !f.marketUrl) return ElMessage.warning('Harmony 版本请填写应用市场链接')
if (f.platform === 'HARMONY' && !f.packageName) return ElMessage.warning('Harmony 版本请填写 bundleName / 包名')
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
if (f.scheduledPublishAt && f.autoPublishAfterReview) {
f.autoPublishAfterReview = false
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.appStoreUrl) fd.append('appStoreUrl', f.appStoreUrl)
if (f.marketUrl) fd.append('marketUrl', f.marketUrl)
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
if (f.file) fd.append('apkFile', f.file)
await updateAdminApi.uploadAppVersion(fd)
ElMessage.success('上传成功')
showUploadApp.value = false
await loadAppVersions()
} finally {
uploadingApp.value = false
}
}
const showUploadRn = ref(false)
const uploadingRn = ref(false)
const rnUploadForm = ref({
moduleId: '',
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
version: '',
minCommonVersion: '',
packageName: '',
note: '',
file: null as File | null,
})
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' | 'HARMONY',
version: parts[2],
minCommonVersion: parts[3],
packageName: parts[4],
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
if (local.packageName) rnUploadForm.value.packageName = local.packageName
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
if (inspected.packageName) rnUploadForm.value.packageName = inspected.packageName
} catch {
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
}
}
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.packageName) fd.append('packageName', f.packageName)
if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd)
ElMessage.success('Bundle 上传成功')
showUploadRn.value = false
await loadRnBundles()
} finally {
uploadingRn.value = false
}
}
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('已发布')
await loadAppVersions()
}
async function unpublishApp(id: string) {
await updateAdminApi.unpublishAppVersion(id)
ElMessage.success('已下架')
await loadAppVersions()
}
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] ?? ''
}
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 []
}
}
function goToStoreGuide() {
router.push(`/apps/${appId}/update-guide`)
}
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; }
/* Store config dialog */
.store-config-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
gap: 20px;
}
.store-config-form {
min-width: 0;
}
.store-config-guide {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
background: var(--el-fill-color-blank);
}
.store-config-guide-title {
font-weight: 600;
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;
}
</style>