2026-04-24 16:16:54 +08:00
|
|
|
|
<template>
|
2026-05-08 18:32:00 +08:00
|
|
|
|
<div
|
|
|
|
|
|
@dragenter.prevent="handleDragEnter"
|
|
|
|
|
|
@dragover.prevent="handleDragOver"
|
|
|
|
|
|
@dragleave="handleDragLeave"
|
|
|
|
|
|
@drop.prevent="handleDrop"
|
|
|
|
|
|
>
|
2026-05-07 13:53:02 +08:00
|
|
|
|
<div v-if="isServicesPortal" class="portal-bar">
|
|
|
|
|
|
<span class="portal-bar-title">版本管理</span>
|
2026-05-07 19:39:47 +08:00
|
|
|
|
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
|
2026-05-07 13:53:02 +08:00
|
|
|
|
<el-option v-for="a in portalApps" :key="a.id" :label="a.name" :value="a.id" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-page-header v-else @back="$router.back()" :content="`版本管理 — ${pageTitle}`" style="margin-bottom:20px" />
|
2026-05-07 19:39:47 +08:00
|
|
|
|
<el-empty v-if="isServicesPortal && !appKey" description="请选择一个应用" style="margin-top:80px" />
|
2026-05-07 13:53:02 +08:00
|
|
|
|
|
2026-05-07 19:39:47 +08:00
|
|
|
|
<template v-if="!isServicesPortal || appKey">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-card>
|
|
|
|
|
|
<el-tabs v-model="activeTab">
|
|
|
|
|
|
<!-- App Versions -->
|
|
|
|
|
|
<el-tab-pane label="App 整包版本" name="app">
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<div class="toolbar responsive-toolbar">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 15:46:40 +08:00
|
|
|
|
<el-radio-button value="HARMONY">Harmony</el-radio-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</el-radio-group>
|
2026-05-03 11:00:13 +08:00
|
|
|
|
<el-button type="primary" @click="openUploadAppDialog">上传新版本</el-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<div class="table-wrap">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-tag v-if="row.scheduledPublishAt && row.publishStatus === 'DRAFT'" type="warning" size="small" style="margin-left:4px">
|
|
|
|
|
|
定时 {{ formatTime(row.scheduledPublishAt) }}
|
|
|
|
|
|
</el-tag>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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}">
|
2026-05-08 18:32:00 +08:00
|
|
|
|
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell">
|
|
|
|
|
|
<div class="store-review-tags">
|
|
|
|
|
|
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
|
|
|
|
|
|
<el-tooltip
|
|
|
|
|
|
v-if="item.state === 'REJECTED' && item.reason"
|
|
|
|
|
|
:content="item.reason"
|
|
|
|
|
|
placement="top"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-tag
|
|
|
|
|
|
:type="reviewTagType(item.state)"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style="margin:2px"
|
|
|
|
|
|
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
|
|
|
|
|
</el-tooltip>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-tag
|
2026-05-08 18:32:00 +08:00
|
|
|
|
v-else
|
2026-04-30 09:49:05 +08:00
|
|
|
|
:type="reviewTagType(item.state)"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style="margin:2px"
|
|
|
|
|
|
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
2026-05-08 18:32:00 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
link
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
class="store-review-detail-btn"
|
|
|
|
|
|
@click="openStoreReviewDetail(row)"
|
|
|
|
|
|
>查看详情</el-button>
|
|
|
|
|
|
</div>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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"
|
2026-04-29 19:08:13 +08:00
|
|
|
|
@click="openPublishDialog(row, 'app')">发布</el-button>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="row.publishStatus === 'DEPRECATED'"
|
|
|
|
|
|
link type="warning" size="small"
|
|
|
|
|
|
@click="openPublishDialog(row, 'app')">重新上架</el-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-button
|
2026-05-08 12:00:34 +08:00
|
|
|
|
v-if="row.publishStatus === 'PUBLISHED' && !publishConfigForm.allowAnonymousUpdateCheck"
|
2026-04-24 16:16:54 +08:00
|
|
|
|
link type="warning" size="small"
|
|
|
|
|
|
@click="openGrayDialog(row, 'app')">灰度</el-button>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="row.publishStatus === 'PUBLISHED'"
|
|
|
|
|
|
link type="primary" size="small"
|
|
|
|
|
|
@click="openPublishDialog(row, 'app')">修改强更</el-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="row.publishStatus === 'PUBLISHED'"
|
|
|
|
|
|
link type="danger" size="small"
|
2026-04-30 09:49:05 +08:00
|
|
|
|
@click="promptUnpublishApp(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>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
</div>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- RN Bundles -->
|
|
|
|
|
|
<el-tab-pane label="RN Bundle 热更新" name="rn">
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<div class="toolbar responsive-toolbar">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<div class="table-wrap">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-tag v-if="row.scheduledPublishAt && row.publishStatus === 'DRAFT'" type="warning" size="small" style="margin-left:4px">
|
|
|
|
|
|
定时 {{ formatTime(row.scheduledPublishAt) }}
|
|
|
|
|
|
</el-tag>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<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 19:08:13 +08:00
|
|
|
|
<el-button v-if="row.publishStatus === 'DRAFT'" link type="success" size="small" @click="openPublishDialog(row, 'rn')">发布</el-button>
|
|
|
|
|
|
<el-button v-if="row.publishStatus === 'DEPRECATED'" link type="warning" size="small" @click="openPublishDialog(row, 'rn')">重新上架</el-button>
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-button v-if="row.publishStatus === 'PUBLISHED' && !publishConfigForm.allowAnonymousUpdateCheck" link type="warning" size="small" @click="openGrayDialog(row, 'rn')">灰度</el-button>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-button v-if="row.publishStatus === 'PUBLISHED'" link type="danger" size="small" @click="promptUnpublishRn(row.id)">下架</el-button>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
</div>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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
|
2026-04-29 19:08:13 +08:00
|
|
|
|
title="应用商店配置按渠道分别维护,App Store 和鸿蒙只填写跳转页,不再填写密钥。审核通知单独配置一次,所有市场共享。"
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type="info"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
style="margin-bottom:16px"
|
|
|
|
|
|
/>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-tabs v-model="storeTab">
|
|
|
|
|
|
<el-tab-pane label="凭据配置" name="configs">
|
|
|
|
|
|
<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="(val: boolean) => toggleStore(store.type, val)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
</div>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
<el-tab-pane label="应用配置指引" name="guide">
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
title="这里说明各商店配置项与跳转页的获取方式。App Store 和鸿蒙只需要跳转页;审核通知只配一次,所有市场共享。"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
style="margin-bottom:16px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="guide-grid">
|
|
|
|
|
|
<el-card v-for="store in STORE_DEFS" :key="store.type" class="guide-card" shadow="never">
|
|
|
|
|
|
<div class="guide-card-title-row">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="guide-card-title">{{ store.label }}</div>
|
|
|
|
|
|
<div class="guide-card-subtitle">{{ store.guideSubtitle }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-tag size="small" type="info">{{ store.shortLabel }}</el-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-steps direction="vertical" :active="store.guideSteps.length" finish-status="success" style="margin:12px 0 8px">
|
|
|
|
|
|
<el-step v-for="step in store.guideSteps" :key="step.title" :title="step.title" :description="step.description" />
|
|
|
|
|
|
</el-steps>
|
|
|
|
|
|
<el-image
|
|
|
|
|
|
v-if="store.guideImage"
|
|
|
|
|
|
:src="store.guideImage"
|
|
|
|
|
|
fit="cover"
|
|
|
|
|
|
class="guide-image"
|
|
|
|
|
|
preview-teleported
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="guide-hint">{{ store.guideHint }}</div>
|
|
|
|
|
|
<el-divider />
|
|
|
|
|
|
<div class="guide-link-title">应用跳转链接的获取方式</div>
|
|
|
|
|
|
<div class="guide-link-hint">{{ store.jumpLinkHint }}</div>
|
|
|
|
|
|
<el-link :href="store.guideUrl" target="_blank" type="primary">查看官方文档</el-link>
|
|
|
|
|
|
</el-card>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</div>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
</el-tabs>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</el-tab-pane>
|
2026-04-29 17:35:52 +08:00
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<!-- Publish Config -->
|
|
|
|
|
|
<el-tab-pane label="发布配置" name="publish-config">
|
2026-04-29 17:35:52 +08:00
|
|
|
|
<el-alert
|
2026-04-29 19:08:13 +08:00
|
|
|
|
title="这里配置灰度默认模式以及成员同步 / 选择回调。发布时仍然会让你最后确认。"
|
2026-04-29 17:35:52 +08:00
|
|
|
|
type="info"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
style="margin-bottom:16px"
|
|
|
|
|
|
/>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<div class="toolbar responsive-toolbar">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-button @click="loadPublishConfig" :loading="loadingPublishConfig">刷新</el-button>
|
|
|
|
|
|
<el-button type="primary" @click="savePublishConfig" :loading="savingPublishConfig">保存配置</el-button>
|
2026-04-29 17:35:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form :model="publishConfigForm" label-width="170px" class="release-config-form">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-form-item label="更新免登录">
|
|
|
|
|
|
<el-switch v-model="publishConfigForm.allowAnonymousUpdateCheck" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="publishConfigForm.allowAnonymousUpdateCheck"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
title="开启后,未登录设备也可以检查更新,但灰度发布相关功能将被禁用。"
|
|
|
|
|
|
style="margin-bottom:16px"
|
|
|
|
|
|
/>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item label="默认灰度模式">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-radio-group v-model="publishConfigForm.grayMode" :disabled="publishConfigForm.allowAnonymousUpdateCheck">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-radio-button value="PERCENT">比例</el-radio-button>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-radio-button value="MEMBERS">成员</el-radio-button>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-radio-group>
|
2026-04-29 17:35:52 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item v-if="publishConfigForm.grayMode === 'PERCENT'" label="默认灰度比例">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-slider v-model="publishConfigForm.defaultGrayPercent" :min="1" :max="100" show-input :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
2026-04-29 17:35:52 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<template v-if="publishConfigForm.grayMode === 'MEMBERS'">
|
|
|
|
|
|
<el-form-item label="成员选择回调">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-form-item>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-form-item label="成员选择密钥">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-input v-model="publishConfigForm.graySelectCallbackSecret" type="password" show-password placeholder="可选,用于成员选择回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
2026-04-30 09:49:05 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item label="成员目录同步回调">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-form-item>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-form-item label="成员目录密钥">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackSecret" type="password" show-password placeholder="可选,用于成员同步回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
|
2026-04-30 09:49:05 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item label="成员选择方式">
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-radio-group v-model="publishConfigForm.graySelectionSource" :disabled="publishConfigForm.allowAnonymousUpdateCheck">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="CALLBACK" :disabled="!hasGraySelectCallback">回调直接返回成员</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item>
|
2026-05-08 12:00:34 +08:00
|
|
|
|
<el-button @click="syncGrayMembers" :loading="loadingGrayMembers" :disabled="!hasGrayDirectorySyncCallback || publishConfigForm.allowAnonymousUpdateCheck">同步成员</el-button>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</template>
|
2026-04-29 17:35:52 +08:00
|
|
|
|
</el-form>
|
|
|
|
|
|
</el-tab-pane>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Operation Logs -->
|
|
|
|
|
|
<el-tab-pane label="操作记录" name="logs">
|
|
|
|
|
|
<div class="toolbar responsive-toolbar">
|
|
|
|
|
|
<el-button @click="loadOperationLogs" :loading="loadingOperationLogs">刷新</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
|
<el-table :data="operationLogs" v-loading="loadingOperationLogs" border stripe>
|
|
|
|
|
|
<el-table-column prop="createdAt" label="时间" width="170">
|
|
|
|
|
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="resourceType" label="对象" width="120">
|
|
|
|
|
|
<template #default="{row}">{{ operationResourceLabel(row.resourceType) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="action" label="操作" width="140">
|
|
|
|
|
|
<template #default="{row}">{{ operationActionLabel(row.action) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="operator" label="操作人" width="140" />
|
|
|
|
|
|
<el-table-column prop="reason" label="原因" width="220" show-overflow-tooltip />
|
|
|
|
|
|
<el-table-column prop="detailJson" label="详情" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{row}">{{ formatDetail(row.detailJson) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-tab-pane>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</el-tabs>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<!-- Publish Dialog -->
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-dialog v-model="showPublish" title="发布 / 重新上架" :width="dialogWidth">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form label-width="120px">
|
|
|
|
|
|
<el-form-item label="发布方式">
|
|
|
|
|
|
<el-radio-group v-model="publishForm.publishImmediately">
|
|
|
|
|
|
<el-radio-button :value="true">立即发布</el-radio-button>
|
|
|
|
|
|
<el-radio-button :value="false">定时发布</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item v-if="!publishForm.publishImmediately" label="计划发布时间">
|
|
|
|
|
|
<el-date-picker
|
|
|
|
|
|
v-model="publishForm.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 v-if="publishTarget?.type === 'app'" label="强制更新">
|
|
|
|
|
|
<el-switch v-model="publishForm.forceUpdate" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="showPublish = false">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" @click="submitPublish" :loading="submittingPublish">确认</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<!-- Gray Release Dialog -->
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-dialog v-model="showGray" title="灰度发布配置" :width="dialogWidth">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form label-width="110px">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item label="灰度方式" v-if="grayForm.enabled">
|
|
|
|
|
|
<el-radio-group v-model="grayForm.grayMode">
|
|
|
|
|
|
<el-radio-button value="PERCENT">比例灰度</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="MEMBERS">指定成员</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item label="灰度比例" v-if="grayForm.enabled && grayForm.grayMode === 'PERCENT'">
|
2026-04-24 16:16:54 +08:00
|
|
|
|
<el-slider v-model="grayForm.percent" :min="1" :max="100" show-input />
|
|
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<template v-else-if="grayForm.enabled && grayForm.grayMode === 'MEMBERS'">
|
|
|
|
|
|
<el-form-item label="成员来源">
|
|
|
|
|
|
<el-radio-group v-model="grayForm.selectionSource">
|
|
|
|
|
|
<el-radio-button value="LOCAL">同步后本地选择</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="CALLBACK">回调直接返回成员</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item label="成员搜索" v-if="grayForm.selectionSource === 'LOCAL'">
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="grayMemberKeyword"
|
|
|
|
|
|
placeholder="搜索 userId / name / 分组"
|
|
|
|
|
|
clearable
|
|
|
|
|
|
style="width: 320px"
|
|
|
|
|
|
@change="loadGrayMembers"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-button style="margin-left: 8px" @click="syncGrayMembers" :loading="loadingGrayMembers">同步成员</el-button>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="grayForm.selectionSource === 'CALLBACK'"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
title="选择成员时将调用发布配置里的成员选择回调,由你的系统直接返回可参与灰度的成员列表。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-if="grayForm.selectionSource === 'LOCAL'" class="gray-member-groups">
|
|
|
|
|
|
<el-empty v-if="!grayMembers.length" description="暂无成员数据,请先同步" />
|
|
|
|
|
|
<el-collapse v-else>
|
|
|
|
|
|
<el-collapse-item v-for="group in grayMembers" :key="group.groupName" :name="group.groupName">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<div class="gray-group-title">
|
|
|
|
|
|
<span>{{ group.groupName }}</span>
|
|
|
|
|
|
<el-tag size="small">{{ group.members.length }}</el-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<el-checkbox-group v-model="grayMemberIds">
|
|
|
|
|
|
<div v-for="member in group.members" :key="member.userId" class="gray-member-row">
|
|
|
|
|
|
<el-checkbox :value="member.userId">
|
|
|
|
|
|
<span class="gray-member-id">{{ member.userId }}</span>
|
|
|
|
|
|
<span class="gray-member-name">{{ member.name || '未命名' }}</span>
|
|
|
|
|
|
</el-checkbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-checkbox-group>
|
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
</el-collapse>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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 -->
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-dialog v-model="showSubmitStore" title="提交应用市场" :width="dialogWidth">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<div v-if="submitStoreVersion">
|
|
|
|
|
|
<p style="margin-bottom:12px">
|
|
|
|
|
|
版本 <strong>{{ submitStoreVersion.versionName }}</strong>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
将提交至以下已配置且启用的市场:
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</p>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form label-width="120px" style="margin-bottom:12px">
|
|
|
|
|
|
<el-form-item label="上架方式">
|
|
|
|
|
|
<el-radio-group v-model="submitStoreMode">
|
|
|
|
|
|
<el-radio-button value="MANUAL">手动上架</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="AUTO_REVIEW">审核完成自动上架</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="SCHEDULED">定时上架</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item v-if="submitStoreMode === 'SCHEDULED'" label="计划时间">
|
|
|
|
|
|
<el-date-picker
|
|
|
|
|
|
v-model="submitStoreScheduledAt"
|
|
|
|
|
|
type="datetime"
|
|
|
|
|
|
placeholder="选择上架时间"
|
|
|
|
|
|
format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
|
|
|
|
|
clearable
|
|
|
|
|
|
/>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-form>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<el-checkbox-group v-model="selectedStores">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<div v-for="store in submissionStoreDefs" :key="store.type" class="store-checkbox-row">
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<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
|
2026-04-29 19:08:13 +08:00
|
|
|
|
v-if="!submissionStoreDefs.length"
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type="warning"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:closable="false"
|
2026-04-29 19:08:13 +08:00
|
|
|
|
title="当前应用暂未配置可用于上架的市场,请先在「应用商店配置」标签页中配置。"
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
<!-- Store Review Detail Dialog -->
|
|
|
|
|
|
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="dialogWidth">
|
|
|
|
|
|
<div v-if="storeReviewDetailVersion">
|
|
|
|
|
|
<el-descriptions :column="1" border style="margin-bottom:16px">
|
|
|
|
|
|
<el-descriptions-item label="版本">
|
|
|
|
|
|
{{ storeReviewDetailVersion.versionName }} · {{ storeReviewDetailVersion.versionCode }}
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="发布状态">
|
|
|
|
|
|
<el-tag :type="statusTagType(storeReviewDetailVersion)" size="small">
|
|
|
|
|
|
{{ statusLabel(storeReviewDetailVersion) }}
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="市场提交目标">
|
|
|
|
|
|
<span v-if="parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).length">
|
|
|
|
|
|
{{ parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).map(storeLabel).join('、') }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-else class="text-muted">未配置</span>
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="上传时间">
|
|
|
|
|
|
{{ formatTime(storeReviewDetailVersion.createdAt) }}
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
|
|
|
|
|
|
|
|
|
|
|
<el-table :data="storeReviewDetailItems" border stripe>
|
|
|
|
|
|
<el-table-column prop="store" label="市场" width="150">
|
|
|
|
|
|
<template #default="{row}">{{ storeLabel(row.store) }}</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="state" label="状态" width="120">
|
|
|
|
|
|
<template #default="{row}">
|
|
|
|
|
|
<el-tag :type="reviewTagType(row.state)" size="small">
|
|
|
|
|
|
{{ reviewLabel(row.state) }}
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="stage" label="阶段" width="120">
|
|
|
|
|
|
<template #default="{row}">
|
|
|
|
|
|
<el-tag v-if="row.stage" type="info" size="small">{{ row.stage }}</el-tag>
|
|
|
|
|
|
<span v-else class="text-muted">-</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="submittedAt" label="提交时间" width="180">
|
|
|
|
|
|
<template #default="{row}">
|
|
|
|
|
|
<span>{{ row.submittedAt ? formatTime(row.submittedAt) : '-' }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="batchId" label="批次 ID" width="220" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{row}">
|
|
|
|
|
|
<span>{{ row.batchId || '-' }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="reason" label="失败原因 / 说明" min-width="260" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{row}">
|
|
|
|
|
|
<el-text v-if="row.reason" :type="row.state === 'REJECTED' ? 'danger' : 'default'">
|
|
|
|
|
|
{{ row.reason }}
|
|
|
|
|
|
</el-text>
|
|
|
|
|
|
<span v-else class="text-muted">-</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="storeReviewDetailItems.some(item => item.state === 'REJECTED')"
|
|
|
|
|
|
type="error"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
style="margin-top:16px"
|
|
|
|
|
|
title="存在审核失败的市场,请查看上方失败原因。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-empty v-else description="暂无商店状态数据" />
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button type="primary" @click="showStoreReviewDetail = false">关闭</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
<!-- Store Credential Config Dialog -->
|
|
|
|
|
|
<el-dialog
|
|
|
|
|
|
v-model="showStoreConfig"
|
|
|
|
|
|
:title="`配置 ${currentStoreDef?.label} 凭据`"
|
2026-04-30 09:49:05 +08:00
|
|
|
|
:width="dialogWidth"
|
2026-04-29 00:36:41 +08:00
|
|
|
|
>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="150px" 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"
|
|
|
|
|
|
/>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-form>
|
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-30 09:49:05 +08:00
|
|
|
|
<el-dialog v-model="showUploadApp" title="上传 App 版本" :width="dialogWidth">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form :model="appUploadForm" label-width="120px">
|
|
|
|
|
|
<el-form-item label="平台">
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-select v-model="appUploadForm.platform" @change="handleAppPlatformChange">
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-option value="ANDROID" label="Android" />
|
|
|
|
|
|
<el-option value="IOS" label="iOS" />
|
|
|
|
|
|
<el-option value="HARMONY" label="Harmony" />
|
|
|
|
|
|
</el-select>
|
2026-04-29 00:36:41 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item label="包名 / Bundle ID">
|
|
|
|
|
|
<el-input v-model="appUploadForm.packageName" readonly />
|
2026-04-29 15:46:40 +08:00
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<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-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-form-item v-if="appUploadForm.platform === 'IOS'" label="App Store 链接(可选)">
|
|
|
|
|
|
<el-input v-model="appUploadForm.appStoreUrl" placeholder="可自动从应用商店配置回显" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item v-if="appUploadForm.platform === 'HARMONY'" label="应用市场链接(可选)">
|
|
|
|
|
|
<el-input v-model="appUploadForm.marketUrl" placeholder="鸿蒙应用市场详情页链接" />
|
|
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-form-item v-if="appUploadForm.platform === 'ANDROID'" label="包文件">
|
2026-05-08 10:22:39 +08:00
|
|
|
|
<el-upload
|
|
|
|
|
|
class="apk-dropzone"
|
|
|
|
|
|
drag
|
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
|
:limit="1"
|
|
|
|
|
|
:on-change="onAppPackageChange"
|
|
|
|
|
|
accept=".apk"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
|
|
|
|
|
<div class="el-upload__text">
|
|
|
|
|
|
将 APK 拖到这里,或 <em>点击选择文件</em>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #tip>
|
|
|
|
|
|
<div class="el-upload__tip">
|
|
|
|
|
|
选择或拖入文件后会自动上传并识别版本信息。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
</el-upload>
|
2026-04-29 15:46:40 +08:00
|
|
|
|
</el-form-item>
|
2026-05-03 11:00:13 +08:00
|
|
|
|
<el-form-item v-if="appUploadForm.platform === 'ANDROID' && appPackageUploadProgress > 0" label="文件进度">
|
2026-04-30 11:47:01 +08:00
|
|
|
|
<div class="upload-progress-block">
|
|
|
|
|
|
<el-progress :percentage="appPackageUploadProgress" :status="appPackageUploadProgress === 100 ? 'success' : undefined" />
|
|
|
|
|
|
<span class="upload-progress-text">{{ appPackageUploadProgress }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-form-item>
|
2026-04-29 19:08:13 +08:00
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="appUploadForm.platform !== 'ANDROID'"
|
2026-04-30 09:49:05 +08:00
|
|
|
|
type="info"
|
2026-04-29 19:08:13 +08:00
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
2026-04-30 09:49:05 +08:00
|
|
|
|
title="iOS 和鸿蒙只记录版本信息,不需要上传安装包。商店跳转链接可选填写,方便后续跳转与回显。"
|
2026-04-29 19:08:13 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="appUploadForm.platform === 'ANDROID'"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
2026-04-30 09:49:05 +08:00
|
|
|
|
title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,可选择强制继续使用。"
|
2026-04-29 19:08:13 +08:00
|
|
|
|
/>
|
2026-04-30 11:47:01 +08:00
|
|
|
|
<el-form-item v-if="appVersionUploadProgress > 0" label="提交进度">
|
|
|
|
|
|
<div class="upload-progress-block">
|
|
|
|
|
|
<el-progress :percentage="appVersionUploadProgress" :status="appVersionUploadProgress === 100 ? 'success' : undefined" />
|
|
|
|
|
|
<span class="upload-progress-text">{{ appVersionUploadProgress }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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 -->
|
2026-04-30 09:49:05 +08:00
|
|
|
|
<el-dialog v-model="showUploadRn" title="上传 RN Bundle" :width="dialogWidth">
|
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 文件">
|
2026-05-08 10:25:50 +08:00
|
|
|
|
<el-upload
|
|
|
|
|
|
class="bundle-dropzone"
|
|
|
|
|
|
drag
|
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
|
:limit="1"
|
|
|
|
|
|
:on-change="onRnBundleChange"
|
|
|
|
|
|
accept=".bundle,.js"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
|
|
|
|
|
<div class="el-upload__text">
|
|
|
|
|
|
将 RN Bundle 拖到这里,或 <em>点击选择文件</em>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #tip>
|
|
|
|
|
|
<div class="el-upload__tip">
|
|
|
|
|
|
选择或拖入文件后会自动识别模块、平台、版本和 Common 版本。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
</el-upload>
|
|
|
|
|
|
</el-form-item>
|
2026-04-30 11:47:01 +08:00
|
|
|
|
<el-form-item v-if="rnInspectUploadProgress > 0" label="识别进度">
|
|
|
|
|
|
<div class="upload-progress-block">
|
|
|
|
|
|
<el-progress :percentage="rnInspectUploadProgress" :status="rnInspectUploadProgress === 100 ? 'success' : undefined" />
|
|
|
|
|
|
<span class="upload-progress-text">{{ rnInspectUploadProgress }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-form-item>
|
2026-04-29 12:33:26 +08:00
|
|
|
|
<el-alert
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
2026-04-29 15:46:40 +08:00
|
|
|
|
title="推荐文件名格式:moduleId__ANDROID__1.0.0__1.0.0__com.example.app.bundle,系统会按命名自动识别模块、平台、版本、最低 Common 版本和包名。"
|
2026-04-29 12:33:26 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<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" />
|
2026-04-29 15:46:40 +08:00
|
|
|
|
<el-option value="HARMONY" label="Harmony" />
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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-29 15:46:40 +08:00
|
|
|
|
<el-form-item label="包名 / Bundle">
|
|
|
|
|
|
<el-input v-model="rnUploadForm.packageName" 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>
|
2026-04-30 11:47:01 +08:00
|
|
|
|
<el-form-item v-if="rnBundleUploadProgress > 0" label="提交进度">
|
|
|
|
|
|
<div class="upload-progress-block">
|
|
|
|
|
|
<el-progress :percentage="rnBundleUploadProgress" :status="rnBundleUploadProgress === 100 ? 'success' : undefined" />
|
|
|
|
|
|
<span class="upload-progress-text">{{ rnBundleUploadProgress }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-form-item>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</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-05-08 18:32:00 +08:00
|
|
|
|
<!-- Global Drag Drop Overlay -->
|
|
|
|
|
|
<div v-if="isDraggingOver" class="drag-overlay">
|
|
|
|
|
|
<div class="drag-overlay-content">
|
|
|
|
|
|
<el-icon size="64"><UploadFilled /></el-icon>
|
|
|
|
|
|
<p class="drag-overlay-title">释放文件以上传</p>
|
|
|
|
|
|
<p class="drag-overlay-hint">支持 .apk、.bundle、.js</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 13:53:02 +08:00
|
|
|
|
</template>
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-08 18:32:00 +08:00
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
2026-05-07 13:53:02 +08:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
2026-04-29 00:36:41 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-05-08 10:22:39 +08:00
|
|
|
|
import { UploadFilled } from '@element-plus/icons-vue'
|
2026-04-29 19:08:13 +08:00
|
|
|
|
import { appApi, type App } from '@/api/app'
|
2026-04-29 17:35:52 +08:00
|
|
|
|
import { fileApi } from '@/api/file'
|
2026-04-29 00:36:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
updateAdminApi,
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type AppPackageInspectResult,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
type AppVersion,
|
2026-04-29 19:08:13 +08:00
|
|
|
|
type GrayMemberGroup,
|
|
|
|
|
|
type GrayMode,
|
|
|
|
|
|
type GraySelectionSource,
|
|
|
|
|
|
type PublishMode,
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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-05-08 18:32:00 +08:00
|
|
|
|
import { connectStoreReviewRealtime, disconnectStoreReviewRealtime } from '@/services/storeReviewRealtime'
|
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()
|
2026-05-07 13:53:02 +08:00
|
|
|
|
const router = useRouter()
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const appKey = route.params.appKey as string
|
2026-05-07 13:53:02 +08:00
|
|
|
|
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
|
|
|
|
|
|
const portalApps = ref<App[]>([])
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const app = ref<App | null>(null)
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const pageTitle = computed(() => app.value?.name ?? appKey)
|
2026-04-30 09:49:05 +08:00
|
|
|
|
const isMobile = ref(false)
|
|
|
|
|
|
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px'))
|
2026-04-24 16:16:54 +08:00
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
const isDraggingOver = ref(false)
|
|
|
|
|
|
let dragCounter = 0
|
|
|
|
|
|
|
|
|
|
|
|
function handleDragEnter(e: DragEvent) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
dragCounter++
|
|
|
|
|
|
isDraggingOver.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleDragOver(e: DragEvent) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (e.dataTransfer) {
|
|
|
|
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleDragLeave(e: DragEvent) {
|
|
|
|
|
|
dragCounter--
|
|
|
|
|
|
if (dragCounter <= 0) {
|
|
|
|
|
|
isDraggingOver.value = false
|
|
|
|
|
|
dragCounter = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDrop(e: DragEvent) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
isDraggingOver.value = false
|
|
|
|
|
|
dragCounter = 0
|
|
|
|
|
|
|
|
|
|
|
|
const files = Array.from(e.dataTransfer?.files || [])
|
|
|
|
|
|
if (!files.length) return
|
|
|
|
|
|
|
|
|
|
|
|
const file = files[0]
|
|
|
|
|
|
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
|
|
|
|
|
|
|
|
|
|
|
if (ext === '.apk') {
|
|
|
|
|
|
openUploadAppDialog()
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
await onAppPackageChange({ raw: file })
|
|
|
|
|
|
} else if (ext === '.bundle' || ext === '.js') {
|
|
|
|
|
|
showUploadRn.value = true
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
await onRnBundleChange({ raw: file })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ElMessage.warning(`不支持的文件类型:${file.name},请拖入 .apk、.bundle 或 .js 文件`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
const activeTab = ref('app')
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const storeTab = ref<'configs' | 'guide'>('configs')
|
2026-04-29 15:46:40 +08:00
|
|
|
|
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
|
|
|
|
|
const rnPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY' | ''>('')
|
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-29 19:08:13 +08:00
|
|
|
|
const loadingPublishConfig = ref(false)
|
|
|
|
|
|
const savingPublishConfig = ref(false)
|
|
|
|
|
|
const publishConfigForm = ref({
|
2026-05-08 12:00:34 +08:00
|
|
|
|
allowAnonymousUpdateCheck: false,
|
2026-04-29 17:35:52 +08:00
|
|
|
|
defaultGrayPercent: 0,
|
2026-04-29 19:08:13 +08:00
|
|
|
|
grayMode: 'PERCENT' as GrayMode,
|
|
|
|
|
|
graySelectionSource: 'LOCAL' as GraySelectionSource,
|
|
|
|
|
|
graySelectCallbackUrl: '',
|
2026-04-30 09:49:05 +08:00
|
|
|
|
graySelectCallbackSecret: '',
|
2026-04-29 19:08:13 +08:00
|
|
|
|
grayDirectorySyncCallbackUrl: '',
|
2026-04-30 09:49:05 +08:00
|
|
|
|
grayDirectorySyncCallbackSecret: '',
|
2026-04-29 17:35:52 +08:00
|
|
|
|
})
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const grayMembers = ref<GrayMemberGroup[]>([])
|
|
|
|
|
|
const loadingGrayMembers = ref(false)
|
|
|
|
|
|
const grayMemberKeyword = ref('')
|
|
|
|
|
|
const grayMemberGroupFilter = ref('')
|
|
|
|
|
|
const grayMemberIds = ref<string[]>([])
|
|
|
|
|
|
const appPackageInspecting = ref(false)
|
2026-04-30 11:47:01 +08:00
|
|
|
|
const appPackageUploadProgress = ref(0)
|
|
|
|
|
|
const appVersionUploadProgress = ref(0)
|
|
|
|
|
|
const rnInspectUploadProgress = ref(0)
|
|
|
|
|
|
const rnBundleUploadProgress = ref(0)
|
2026-04-30 09:49:05 +08:00
|
|
|
|
const operationLogs = ref<{
|
|
|
|
|
|
id: string
|
2026-05-07 19:39:47 +08:00
|
|
|
|
appKey: string
|
2026-04-30 09:49:05 +08:00
|
|
|
|
resourceType: string
|
|
|
|
|
|
resourceId: string
|
|
|
|
|
|
action: string
|
|
|
|
|
|
operator?: string
|
|
|
|
|
|
reason?: string
|
|
|
|
|
|
detailJson?: string
|
|
|
|
|
|
createdAt: string
|
|
|
|
|
|
}[]>([])
|
|
|
|
|
|
const loadingOperationLogs = ref(false)
|
2026-05-08 18:32:00 +08:00
|
|
|
|
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
|
|
|
|
|
|
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
|
|
|
|
|
|
const hasAnyGrayCallback = computed(() => hasGraySelectCallback.value || hasGrayDirectorySyncCallback.value)
|
2026-05-08 12:00:34 +08:00
|
|
|
|
const allowAnonymousUpdateCheck = computed(() => Boolean(publishConfigForm.value.allowAnonymousUpdateCheck))
|
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 }
|
2026-04-29 19:08:13 +08:00
|
|
|
|
function marketUrlField(placeholder: string): FieldDef {
|
2026-04-30 09:49:05 +08:00
|
|
|
|
return { key: 'marketUrl', label: '应用市场跳转页面(可选)', placeholder }
|
2026-04-29 19:08:13 +08:00
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
type StoreDef = {
|
|
|
|
|
|
type: StoreType
|
|
|
|
|
|
label: string
|
|
|
|
|
|
shortLabel: string
|
|
|
|
|
|
fields: FieldDef[]
|
|
|
|
|
|
guideSubtitle: string
|
|
|
|
|
|
guideUrl: string
|
|
|
|
|
|
guideSteps: GuideStep[]
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: string
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('华为应用市场详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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 填到这里。' },
|
|
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: '华为应用的详情页链接一般形如 appgallery.huawei.com/app/detail?id=包名,可直接在应用详情页复制。',
|
|
|
|
|
|
guideHint: '与后端 submitToHuawei 读取逻辑一致:clientId / clientSecret + marketUrl。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
{ key: 'publicKey', label: '公钥证书', type: 'textarea', placeholder: '-----BEGIN CERTIFICATE-----\n...' },
|
2026-04-29 00:36:41 +08:00
|
|
|
|
{ key: 'privateKey', label: 'RSA 私钥', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
|
2026-04-29 19:08:13 +08:00
|
|
|
|
marketUrlField('小米应用商店详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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: '这里保存的是服务端上传所需凭据。' },
|
|
|
|
|
|
],
|
2026-05-07 19:39:47 +08:00
|
|
|
|
jumpLinkHint: '小米应用商店详情页可从应用的商店公开链接或发布页面复制,通常包含 appKey 或 package 信息。',
|
2026-04-29 19:08:13 +08:00
|
|
|
|
guideHint: '当前字段为 username / publicKey / privateKey,与后端服务一致。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('OPPO 软件商店详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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。' },
|
|
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: 'OPPO 详情页链接请填写应用在软件商店的公开详情页地址,便于发布记录跳转。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('vivo 应用商店详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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: '与后端提交服务字段保持一致。' },
|
|
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: 'vivo 应用商店详情页链接可直接从商店发布后的公开详情页复制。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('荣耀应用市场详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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 对应。' },
|
|
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: '荣耀应用的详情页链接请填写应用在荣耀市场的公开详情页地址。',
|
2026-04-30 09:49:05 +08:00
|
|
|
|
guideHint: '与后端 Honor 提交流程完全一致;跳转页可选填写。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('App Store 详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
guideSubtitle: 'App Store 只保留应用商店跳转链接',
|
|
|
|
|
|
guideUrl: 'https://developer.apple.com/app-store/',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideSteps: [
|
2026-04-29 19:08:13 +08:00
|
|
|
|
{ title: '进入 App Store Connect', description: '打开对应应用的详情页或 App Store 公开页面。' },
|
|
|
|
|
|
{ title: '复制 App Store 页面链接', description: '这里只需要保存跳转页,不需要再填密钥。' },
|
|
|
|
|
|
{ title: '保存配置', description: '配置完成后可在版本提醒中直接跳转。' },
|
2026-04-29 12:33:26 +08:00
|
|
|
|
],
|
2026-04-30 09:49:05 +08:00
|
|
|
|
jumpLinkHint: 'App Store 链接可选填写,通常以 apps.apple.com 开头,需要跳转时再补。',
|
|
|
|
|
|
guideHint: '不再配置 Team ID、Key ID 或私钥;发布侧只在需要跳转时使用该链接。',
|
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 19:08:13 +08:00
|
|
|
|
marketUrlField('Google Play 详情页链接'),
|
2026-04-29 00:36:41 +08:00
|
|
|
|
],
|
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: '这里保存的是上传服务端所需内容。' },
|
|
|
|
|
|
],
|
2026-04-29 19:08:13 +08:00
|
|
|
|
jumpLinkHint: 'Google Play 链接直接填写商店公开详情页即可。',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
guideHint: '目前字段以 JSON 方式保存,方便服务端直接读取。',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
},
|
2026-04-29 19:08:13 +08:00
|
|
|
|
{
|
|
|
|
|
|
type: 'HARMONY_APP',
|
|
|
|
|
|
label: '鸿蒙应用',
|
|
|
|
|
|
shortLabel: '鸿蒙',
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
marketUrlField('鸿蒙应用市场详情页链接'),
|
|
|
|
|
|
],
|
|
|
|
|
|
guideSubtitle: '鸿蒙应用只保留市场跳转页',
|
|
|
|
|
|
guideUrl: 'https://developer.huawei.com/consumer/cn/',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '打开鸿蒙应用市场', description: '确认目标应用已在鸿蒙市场上架或待上架。' },
|
|
|
|
|
|
{ title: '复制详情页链接', description: '填写市场跳转页面,便于版本提醒直接跳转。' },
|
|
|
|
|
|
{ title: '保存配置', description: '配置完成后可在版本提醒里引用。' },
|
|
|
|
|
|
],
|
2026-04-30 09:49:05 +08:00
|
|
|
|
jumpLinkHint: '鸿蒙应用市场详情页链接可选填写,通常使用 appgallery.huawei.com/app/detail?id=包名。',
|
2026-04-29 19:08:13 +08:00
|
|
|
|
guideHint: '这里只保存鸿蒙应用市场的独立跳转页,不参与 Android 审核提交。',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'REVIEW_WEBHOOK',
|
|
|
|
|
|
label: '审核通知',
|
|
|
|
|
|
shortLabel: '审核通知',
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
{ key: 'webhookUrl', label: '通知地址', placeholder: 'https://your.service/store/webhook' },
|
|
|
|
|
|
{ key: 'secret', label: '签名密钥', type: 'password', placeholder: '可选,用于回调验签' },
|
|
|
|
|
|
],
|
|
|
|
|
|
guideSubtitle: '所有市场审核状态共用这一套通知配置',
|
|
|
|
|
|
guideUrl: 'https://cloud.tencent.com/document/product/269/32431',
|
|
|
|
|
|
guideSteps: [
|
|
|
|
|
|
{ title: '配置回调地址', description: '填写你的业务系统回调 URL。' },
|
|
|
|
|
|
{ title: '配置签名密钥', description: '如需验签,可同步设置 secret。' },
|
|
|
|
|
|
{ title: '保存并启用', description: '后续任何市场审核状态变化都会复用这一个通知。' },
|
|
|
|
|
|
],
|
|
|
|
|
|
jumpLinkHint: '不涉及应用市场跳转页,仅配置回调地址。',
|
|
|
|
|
|
guideHint: '该配置对所有市场共用,不再按渠道重复配置。',
|
|
|
|
|
|
},
|
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 19:08:13 +08:00
|
|
|
|
const enabledStores = computed(() => STORE_DEFS.filter(s =>
|
|
|
|
|
|
!['APP_STORE', 'HARMONY_APP', 'REVIEW_WEBHOOK'].includes(s.type) && isStoreEnabled(s.type),
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
const submissionStoreDefs = computed(() => STORE_DEFS.filter(s =>
|
|
|
|
|
|
!['APP_STORE', 'HARMONY_APP', 'REVIEW_WEBHOOK'].includes(s.type) && 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 {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
await updateAdminApi.saveStoreConfig(appKey, type, cfg.configJson ?? '{}', enabled)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.getStoreConfigs(appKey)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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-05-07 13:53:02 +08:00
|
|
|
|
function switchApp(val: string) {
|
|
|
|
|
|
router.push(`/services/update/${val}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
async function loadApp() {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await appApi.get(appKey)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
app.value = res.data.data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (currentStoreDef.value.type === 'REVIEW_WEBHOOK') {
|
|
|
|
|
|
const webhookUrl = storeConfigForm.value.values.webhookUrl?.trim() ?? ''
|
|
|
|
|
|
if (!webhookUrl) {
|
|
|
|
|
|
ElMessage.warning('请填写审核通知地址')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
savingStoreConfig.value = true
|
2026-04-24 16:16:54 +08:00
|
|
|
|
try {
|
2026-04-29 00:36:41 +08:00
|
|
|
|
await updateAdminApi.saveStoreConfig(
|
2026-05-07 19:39:47 +08:00
|
|
|
|
appKey,
|
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 {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
await updateAdminApi.deleteStoreConfig(appKey, type)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
function normalizePublishConfig(raw: Record<string, unknown> | null | undefined) {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
const normalizeCallbackUrl = (value: unknown) => {
|
|
|
|
|
|
const text = String(value ?? '').trim()
|
|
|
|
|
|
return /^https?:\/\//i.test(text) ? text : ''
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const grayMode = String(raw?.grayMode ?? 'PERCENT') as GrayMode
|
|
|
|
|
|
const graySelectionSource = String(raw?.graySelectionSource ?? 'LOCAL') as GraySelectionSource
|
2026-04-29 17:35:52 +08:00
|
|
|
|
return {
|
2026-05-08 12:00:34 +08:00
|
|
|
|
allowAnonymousUpdateCheck: Boolean(raw?.allowAnonymousUpdateCheck ?? raw?.defaultAllowAnonymousUpdateCheck ?? false),
|
2026-04-29 19:08:13 +08:00
|
|
|
|
defaultGrayPercent: Number((raw as Record<string, unknown>)?.defaultGrayPercent ?? 0),
|
|
|
|
|
|
grayMode,
|
|
|
|
|
|
graySelectionSource,
|
2026-04-30 11:47:01 +08:00
|
|
|
|
graySelectCallbackUrl: normalizeCallbackUrl((raw as Record<string, unknown>)?.graySelectCallbackUrl),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
graySelectCallbackSecret: String((raw as Record<string, unknown>)?.graySelectCallbackSecret ?? ''),
|
2026-04-30 11:47:01 +08:00
|
|
|
|
grayDirectorySyncCallbackUrl: normalizeCallbackUrl((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
grayDirectorySyncCallbackSecret: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackSecret ?? ''),
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
function parsePublishConfig(config?: string | null) {
|
|
|
|
|
|
if (!config) {
|
|
|
|
|
|
return normalizePublishConfig({})
|
|
|
|
|
|
}
|
2026-04-29 17:35:52 +08:00
|
|
|
|
try {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
return normalizePublishConfig(JSON.parse(config) as Record<string, unknown>)
|
2026-04-29 17:35:52 +08:00
|
|
|
|
} catch {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
return normalizePublishConfig({})
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
async function loadPublishConfig() {
|
|
|
|
|
|
loadingPublishConfig.value = true
|
2026-04-29 17:35:52 +08:00
|
|
|
|
try {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.getPublishConfig(appKey)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
publishConfigForm.value = parsePublishConfig(res.data.data.configJson)
|
2026-05-08 12:00:34 +08:00
|
|
|
|
if (allowAnonymousUpdateCheck.value) {
|
|
|
|
|
|
publishConfigForm.value.grayMode = 'PERCENT'
|
|
|
|
|
|
publishConfigForm.value.graySelectionSource = 'LOCAL'
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (publishConfigForm.value.grayMode === 'MEMBERS' && !hasAnyGrayCallback.value) {
|
|
|
|
|
|
publishConfigForm.value.grayMode = 'PERCENT'
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
publishConfigForm.value = normalizePublishConfig({})
|
2026-04-29 17:35:52 +08:00
|
|
|
|
} finally {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
loadingPublishConfig.value = false
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
async function savePublishConfig() {
|
|
|
|
|
|
savingPublishConfig.value = true
|
2026-04-29 17:35:52 +08:00
|
|
|
|
try {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
const normalizeCallbackUrl = (value: string) => {
|
|
|
|
|
|
const trimmed = value.trim()
|
|
|
|
|
|
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const payload = {
|
|
|
|
|
|
...publishConfigForm.value,
|
|
|
|
|
|
defaultGrayPercent: Math.min(Math.max(Number(publishConfigForm.value.defaultGrayPercent || 0), 0), 100),
|
2026-04-30 11:47:01 +08:00
|
|
|
|
graySelectCallbackUrl: normalizeCallbackUrl(publishConfigForm.value.graySelectCallbackUrl),
|
|
|
|
|
|
grayDirectorySyncCallbackUrl: normalizeCallbackUrl(publishConfigForm.value.grayDirectorySyncCallbackUrl),
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
2026-05-08 12:00:34 +08:00
|
|
|
|
if (payload.allowAnonymousUpdateCheck) {
|
|
|
|
|
|
payload.grayMode = 'PERCENT'
|
|
|
|
|
|
payload.graySelectionSource = 'LOCAL'
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (payload.grayMode === 'MEMBERS' && !hasAnyGrayCallback.value) {
|
|
|
|
|
|
ElMessage.warning('成员模式至少需要配置一个回调地址')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (payload.grayMode === 'MEMBERS' && !hasGrayDirectorySyncCallback.value) {
|
|
|
|
|
|
payload.graySelectionSource = 'CALLBACK'
|
|
|
|
|
|
}
|
2026-05-07 19:39:47 +08:00
|
|
|
|
await updateAdminApi.savePublishConfig(appKey, payload)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
ElMessage.success('发布配置已保存')
|
2026-04-29 17:35:52 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('保存失败')
|
|
|
|
|
|
} finally {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
savingPublishConfig.value = false
|
2026-04-29 17:35:52 +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[]>([])
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const submitStoreMode = ref<PublishMode>('MANUAL')
|
|
|
|
|
|
const submitStoreScheduledAt = ref('')
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
const showStoreReviewDetail = ref(false)
|
|
|
|
|
|
const storeReviewDetailVersion = ref<AppVersion | null>(null)
|
|
|
|
|
|
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([])
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
function openSubmitStoreDialog(row: AppVersion) {
|
|
|
|
|
|
submitStoreVersion.value = row
|
|
|
|
|
|
selectedStores.value = enabledStores.value.map(s => s.type)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
submitStoreMode.value = row.storeSubmitMode ?? 'MANUAL'
|
|
|
|
|
|
submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? ''
|
2026-04-29 00:36:41 +08:00
|
|
|
|
showSubmitStore.value = true
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
function parseStoreTargets(json?: string) {
|
|
|
|
|
|
if (!json) return []
|
|
|
|
|
|
try {
|
|
|
|
|
|
const value = JSON.parse(json)
|
|
|
|
|
|
return Array.isArray(value) ? value.map(item => String(item)) : []
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openStoreReviewDetail(row: AppVersion) {
|
|
|
|
|
|
storeReviewDetailVersion.value = row
|
|
|
|
|
|
storeReviewDetailItems.value = parseStoreReview(row.storeReviewStatus)
|
|
|
|
|
|
showStoreReviewDetail.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:36:41 +08:00
|
|
|
|
async function confirmSubmitToStores() {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (!submitStoreVersion.value || !selectedStores.value.length) {
|
|
|
|
|
|
ElMessage.warning('请选择至少一个可用市场')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (submitStoreMode.value === 'SCHEDULED' && !submitStoreScheduledAt.value) {
|
|
|
|
|
|
ElMessage.warning('请选择定时上架时间')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
submittingToStores.value = true
|
|
|
|
|
|
try {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
await updateAdminApi.executeSubmitToStores(
|
|
|
|
|
|
submitStoreVersion.value.id,
|
|
|
|
|
|
selectedStores.value,
|
|
|
|
|
|
submitStoreMode.value,
|
|
|
|
|
|
submitStoreScheduledAt.value || undefined,
|
|
|
|
|
|
submitStoreMode.value === 'AUTO_REVIEW',
|
|
|
|
|
|
)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
ElMessage.success('已提交,服务端正在向应用市场上传,审核状态将通过 Webhook 或刷新页面查看')
|
|
|
|
|
|
showSubmitStore.value = false
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadAppVersions()
|
2026-04-30 09:49:05 +08:00
|
|
|
|
await loadOperationLogs()
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} 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)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const grayForm = ref({
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
grayMode: 'PERCENT' as GrayMode,
|
|
|
|
|
|
percent: 10,
|
|
|
|
|
|
selectionSource: 'LOCAL' as GraySelectionSource,
|
|
|
|
|
|
})
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
|
2026-05-08 12:00:34 +08:00
|
|
|
|
if (allowAnonymousUpdateCheck.value) {
|
|
|
|
|
|
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
grayTarget.value = { id: row.id, type }
|
2026-04-29 19:08:13 +08:00
|
|
|
|
grayForm.value = {
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
grayMode: hasAnyGrayCallback.value ? publishConfigForm.value.grayMode : 'PERCENT',
|
|
|
|
|
|
percent: publishConfigForm.value.defaultGrayPercent || 10,
|
|
|
|
|
|
selectionSource: hasGrayDirectorySyncCallback.value
|
|
|
|
|
|
? publishConfigForm.value.graySelectionSource
|
|
|
|
|
|
: (hasGraySelectCallback.value ? 'CALLBACK' : 'LOCAL'),
|
|
|
|
|
|
}
|
|
|
|
|
|
grayMemberIds.value = []
|
|
|
|
|
|
grayMemberKeyword.value = ''
|
|
|
|
|
|
grayMemberGroupFilter.value = ''
|
|
|
|
|
|
grayMembers.value = []
|
2026-04-24 16:16:54 +08:00
|
|
|
|
showGray.value = true
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (grayForm.value.selectionSource === 'LOCAL' && hasGrayDirectorySyncCallback.value) {
|
|
|
|
|
|
loadGrayMembers()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadGrayMembers() {
|
|
|
|
|
|
if (!showGray.value || !hasGrayDirectorySyncCallback.value) return
|
|
|
|
|
|
loadingGrayMembers.value = true
|
|
|
|
|
|
try {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.listGrayMembers(appKey, grayMemberKeyword.value || undefined, grayMemberGroupFilter.value || undefined)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
grayMembers.value = res.data.data
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
grayMembers.value = []
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingGrayMembers.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function syncGrayMembers() {
|
|
|
|
|
|
if (!hasGrayDirectorySyncCallback.value) {
|
|
|
|
|
|
ElMessage.warning('未配置成员目录同步回调,无法同步成员')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
loadingGrayMembers.value = true
|
|
|
|
|
|
try {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.syncGrayMembers(appKey)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
grayMembers.value = res.data.data
|
|
|
|
|
|
ElMessage.success('成员已同步')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('同步失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingGrayMembers.value = false
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitGray() {
|
|
|
|
|
|
if (!grayTarget.value) return
|
2026-05-08 12:00:34 +08:00
|
|
|
|
if (allowAnonymousUpdateCheck.value) {
|
|
|
|
|
|
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !hasGrayDirectorySyncCallback.value) {
|
|
|
|
|
|
ElMessage.warning('未配置成员目录同步回调,无法选择本地成员')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'CALLBACK' && !hasGraySelectCallback.value) {
|
|
|
|
|
|
ElMessage.warning('未配置成员选择回调,无法使用回调成员选择')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !grayMemberIds.value.length) {
|
|
|
|
|
|
ElMessage.warning('请选择至少一个灰度成员')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
submittingGray.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id, type } = grayTarget.value
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const payload: {
|
|
|
|
|
|
enabled: boolean
|
|
|
|
|
|
grayMode: GrayMode
|
|
|
|
|
|
percent?: number
|
|
|
|
|
|
memberIds?: string[]
|
|
|
|
|
|
selectionSource?: GraySelectionSource
|
|
|
|
|
|
} = {
|
|
|
|
|
|
enabled: grayForm.value.enabled,
|
|
|
|
|
|
grayMode: grayForm.value.grayMode,
|
|
|
|
|
|
selectionSource: grayForm.value.selectionSource,
|
|
|
|
|
|
}
|
|
|
|
|
|
if (grayForm.value.grayMode === 'PERCENT') {
|
|
|
|
|
|
payload.percent = grayForm.value.percent
|
|
|
|
|
|
} else {
|
|
|
|
|
|
payload.memberIds = grayMemberIds.value
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
if (type === 'app') {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
await updateAdminApi.grayAppVersion(id, payload)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadAppVersions()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
} else {
|
2026-04-29 19:08:13 +08:00
|
|
|
|
await updateAdminApi.grayRnBundle(id, payload)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
await loadRnBundles()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
}
|
2026-04-30 09:49:05 +08:00
|
|
|
|
await loadOperationLogs()
|
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({
|
2026-04-29 15:46:40 +08:00
|
|
|
|
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
packageName: '',
|
|
|
|
|
|
versionName: '',
|
|
|
|
|
|
versionCode: 1,
|
|
|
|
|
|
changeLog: '',
|
2026-04-30 09:49:05 +08:00
|
|
|
|
appStoreUrl: '',
|
|
|
|
|
|
marketUrl: '',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
file: null as File | null,
|
2026-04-29 17:35:52 +08:00
|
|
|
|
fileUrl: '',
|
2026-04-29 00:36:41 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
function getStoreJumpUrl(storeType: StoreType) {
|
|
|
|
|
|
const cfg = storeConfigs.value.find(c => c.storeType === storeType)
|
|
|
|
|
|
if (!cfg?.configJson) return ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(cfg.configJson) as Record<string, unknown>
|
|
|
|
|
|
const jump = String(parsed.marketUrl ?? '').trim()
|
|
|
|
|
|
return jump
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 11:00:13 +08:00
|
|
|
|
function openUploadAppDialog() {
|
|
|
|
|
|
appUploadForm.value.platform = appPlatform.value
|
|
|
|
|
|
appUploadForm.value.versionName = ''
|
|
|
|
|
|
appUploadForm.value.versionCode = 1
|
|
|
|
|
|
appUploadForm.value.changeLog = ''
|
|
|
|
|
|
appUploadForm.value.file = null
|
|
|
|
|
|
appUploadForm.value.fileUrl = ''
|
|
|
|
|
|
appPackageUploadProgress.value = 0
|
|
|
|
|
|
appPackageInspecting.value = false
|
|
|
|
|
|
handleAppPlatformChange()
|
|
|
|
|
|
showUploadApp.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
function handleAppPlatformChange() {
|
|
|
|
|
|
if (appUploadForm.value.platform === 'ANDROID') {
|
|
|
|
|
|
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appUploadForm.value.platform === 'IOS') {
|
|
|
|
|
|
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
|
|
|
|
|
|
appUploadForm.value.appStoreUrl = getStoreJumpUrl('APP_STORE') || appUploadForm.value.appStoreUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appUploadForm.value.platform === 'HARMONY') {
|
|
|
|
|
|
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
|
|
|
|
|
|
appUploadForm.value.marketUrl = getStoreJumpUrl('HARMONY_APP') || appUploadForm.value.marketUrl
|
|
|
|
|
|
}
|
2026-05-03 11:00:13 +08:00
|
|
|
|
// iOS 和鸿蒙不需要上传文件,切换时清理 Android 遗留的文件状态
|
|
|
|
|
|
appUploadForm.value.file = null
|
|
|
|
|
|
appUploadForm.value.fileUrl = ''
|
|
|
|
|
|
appPackageUploadProgress.value = 0
|
|
|
|
|
|
appPackageInspecting.value = false
|
2026-04-30 09:49:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:33:26 +08:00
|
|
|
|
async function onAppPackageChange(uploadFile: { raw?: File } | null) {
|
|
|
|
|
|
const file = uploadFile?.raw ?? null
|
|
|
|
|
|
appUploadForm.value.file = file
|
2026-04-29 17:35:52 +08:00
|
|
|
|
appUploadForm.value.fileUrl = ''
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (!file) return
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
appPackageInspecting.value = true
|
2026-04-30 11:47:01 +08:00
|
|
|
|
appPackageUploadProgress.value = 0
|
2026-04-29 12:33:26 +08:00
|
|
|
|
try {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
const uploaded = await fileApi.uploadFile(file, null, (percent) => {
|
|
|
|
|
|
appPackageUploadProgress.value = percent
|
|
|
|
|
|
})
|
|
|
|
|
|
appPackageUploadProgress.value = 100
|
2026-04-29 17:35:52 +08:00
|
|
|
|
const fileInfo = uploaded.data.data
|
|
|
|
|
|
appUploadForm.value.fileUrl = fileInfo.url
|
|
|
|
|
|
const res = await updateAdminApi.inspectAppPackage(fileInfo.url)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
const inspected = res.data.data as AppPackageInspectResult
|
2026-04-29 19:08:13 +08:00
|
|
|
|
const currentPackageName = app.value?.packageName?.trim() ?? ''
|
|
|
|
|
|
if (currentPackageName && inspected.packageName && inspected.packageName !== currentPackageName) {
|
2026-04-30 09:49:05 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm(
|
|
|
|
|
|
`当前应用包名是 ${currentPackageName},你选择的文件包名是 ${inspected.packageName}。是否强制继续使用这个包?`,
|
|
|
|
|
|
'包名不一致',
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
confirmButtonText: '强制使用',
|
|
|
|
|
|
cancelButtonText: '重新选择',
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
appUploadForm.value.packageName = inspected.packageName
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
appUploadForm.value.file = null
|
|
|
|
|
|
appUploadForm.value.fileUrl = ''
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-29 15:46:40 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (inspected.platform) appUploadForm.value.platform = inspected.platform
|
|
|
|
|
|
if (inspected.packageName) appUploadForm.value.packageName = inspected.packageName
|
2026-04-29 12:33:26 +08:00
|
|
|
|
if (inspected.versionName) appUploadForm.value.versionName = inspected.versionName
|
|
|
|
|
|
if (typeof inspected.versionCode === 'number') appUploadForm.value.versionCode = inspected.versionCode
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.warning('已选择文件,但未能完整识别,请补全版本信息后上传')
|
2026-04-29 19:08:13 +08:00
|
|
|
|
} finally {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
appPackageUploadProgress.value = 0
|
2026-04-29 19:08:13 +08:00
|
|
|
|
appPackageInspecting.value = false
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
async function submitAppUpload() {
|
|
|
|
|
|
const f = appUploadForm.value
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (f.platform === 'ANDROID' && !f.fileUrl) return ElMessage.warning('请先选择 Android 安装包文件')
|
2026-04-24 16:16:54 +08:00
|
|
|
|
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
|
2026-04-29 12:33:26 +08:00
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
uploadingApp.value = true
|
2026-04-30 11:47:01 +08:00
|
|
|
|
appVersionUploadProgress.value = 0
|
2026-04-24 16:16:54 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const fd = new FormData()
|
2026-05-07 19:39:47 +08:00
|
|
|
|
fd.append('appKey', appKey)
|
2026-04-24 16:16:54 +08:00
|
|
|
|
fd.append('platform', f.platform)
|
|
|
|
|
|
fd.append('versionName', f.versionName)
|
|
|
|
|
|
fd.append('versionCode', String(f.versionCode))
|
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-30 09:49:05 +08:00
|
|
|
|
if (f.appStoreUrl) fd.append('appStoreUrl', f.appStoreUrl)
|
|
|
|
|
|
if (f.marketUrl) fd.append('marketUrl', f.marketUrl)
|
2026-04-29 19:08:13 +08:00
|
|
|
|
if (f.platform === 'ANDROID' && f.fileUrl) fd.append('apkUrl', f.fileUrl)
|
2026-04-30 11:47:01 +08:00
|
|
|
|
await updateAdminApi.uploadAppVersion(fd, (percent) => {
|
|
|
|
|
|
appVersionUploadProgress.value = percent
|
|
|
|
|
|
})
|
|
|
|
|
|
appVersionUploadProgress.value = 100
|
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 {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
appVersionUploadProgress.value = 0
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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: '',
|
2026-04-29 15:46:40 +08:00
|
|
|
|
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
version: '',
|
|
|
|
|
|
minCommonVersion: '',
|
2026-04-29 15:46:40 +08:00
|
|
|
|
packageName: '',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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],
|
2026-04-29 15:46:40 +08:00
|
|
|
|
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS' | 'HARMONY',
|
2026-04-29 12:33:26 +08:00
|
|
|
|
version: parts[2],
|
|
|
|
|
|
minCommonVersion: parts[3],
|
2026-04-29 15:46:40 +08:00
|
|
|
|
packageName: parts[4],
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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
|
2026-04-29 15:46:40 +08:00
|
|
|
|
if (local.packageName) rnUploadForm.value.packageName = local.packageName
|
2026-04-29 12:33:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('bundle', file)
|
2026-04-30 11:47:01 +08:00
|
|
|
|
rnInspectUploadProgress.value = 0
|
2026-04-29 12:33:26 +08:00
|
|
|
|
try {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
const res = await updateAdminApi.inspectRnBundle(formData, (percent) => {
|
|
|
|
|
|
rnInspectUploadProgress.value = percent
|
|
|
|
|
|
})
|
|
|
|
|
|
rnInspectUploadProgress.value = 100
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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
|
2026-04-29 15:46:40 +08:00
|
|
|
|
if (inspected.packageName) rnUploadForm.value.packageName = inspected.packageName
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
|
2026-04-30 11:47:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
rnInspectUploadProgress.value = 0
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-04-30 11:47:01 +08:00
|
|
|
|
rnBundleUploadProgress.value = 0
|
2026-04-24 16:16:54 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const fd = new FormData()
|
2026-05-07 19:39:47 +08:00
|
|
|
|
fd.append('appKey', appKey)
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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)
|
2026-04-29 15:46:40 +08:00
|
|
|
|
if (f.packageName) fd.append('packageName', f.packageName)
|
2026-04-24 16:16:54 +08:00
|
|
|
|
if (f.note) fd.append('note', f.note)
|
|
|
|
|
|
fd.append('bundle', f.file)
|
2026-04-30 11:47:01 +08:00
|
|
|
|
await updateAdminApi.uploadRnBundle(fd, (percent) => {
|
|
|
|
|
|
rnBundleUploadProgress.value = percent
|
|
|
|
|
|
})
|
|
|
|
|
|
rnBundleUploadProgress.value = 100
|
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 {
|
2026-04-30 11:47:01 +08:00
|
|
|
|
rnBundleUploadProgress.value = 0
|
2026-04-29 12:33:26 +08:00
|
|
|
|
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 {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.listAppVersions(appKey, appPlatform.value)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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 {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.listRnBundles(appKey, rnModuleFilter.value || undefined, rnPlatform.value || undefined)
|
2026-04-29 00:36:41 +08:00
|
|
|
|
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 19:08:13 +08:00
|
|
|
|
const showPublish = ref(false)
|
|
|
|
|
|
const submittingPublish = ref(false)
|
|
|
|
|
|
const publishTarget = ref<{ id: string; type: 'app' | 'rn'; row?: AppVersion | RnBundle } | null>(null)
|
|
|
|
|
|
const publishForm = ref({
|
|
|
|
|
|
publishImmediately: true,
|
|
|
|
|
|
scheduledPublishAt: '',
|
|
|
|
|
|
forceUpdate: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function openPublishDialog(row: AppVersion | RnBundle, type: 'app' | 'rn') {
|
|
|
|
|
|
publishTarget.value = { id: row.id, type, row }
|
|
|
|
|
|
publishForm.value = {
|
|
|
|
|
|
publishImmediately: !row.scheduledPublishAt,
|
|
|
|
|
|
scheduledPublishAt: row.scheduledPublishAt ?? '',
|
|
|
|
|
|
forceUpdate: 'forceUpdate' in row ? Boolean((row as AppVersion).forceUpdate) : false,
|
|
|
|
|
|
}
|
|
|
|
|
|
showPublish.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitPublish() {
|
|
|
|
|
|
if (!publishTarget.value) return
|
|
|
|
|
|
if (!publishForm.value.publishImmediately && !publishForm.value.scheduledPublishAt) {
|
|
|
|
|
|
ElMessage.warning('请选择计划发布时间')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
submittingPublish.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
publishImmediately: publishForm.value.publishImmediately,
|
|
|
|
|
|
scheduledPublishAt: publishForm.value.scheduledPublishAt || undefined,
|
|
|
|
|
|
forceUpdate: publishForm.value.forceUpdate,
|
|
|
|
|
|
}
|
|
|
|
|
|
if (publishTarget.value.type === 'app') {
|
|
|
|
|
|
await updateAdminApi.publishAppVersion(publishTarget.value.id, body)
|
|
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await updateAdminApi.publishRnBundle(publishTarget.value.id, body)
|
|
|
|
|
|
await loadRnBundles()
|
|
|
|
|
|
}
|
2026-04-30 09:49:05 +08:00
|
|
|
|
await loadOperationLogs()
|
2026-04-29 19:08:13 +08:00
|
|
|
|
ElMessage.success('已保存发布操作')
|
|
|
|
|
|
showPublish.value = false
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('发布失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submittingPublish.value = false
|
|
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
async function promptUnpublishReason(title: string) {
|
|
|
|
|
|
let value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm('下架后该版本将不再作为可发布版本,请确认继续。', title, {
|
|
|
|
|
|
confirmButtonText: '下一步',
|
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
({ value } = await ElMessageBox.prompt('请输入下架原因', title, {
|
|
|
|
|
|
confirmButtonText: '下架',
|
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
|
inputPlaceholder: '请填写下架原因',
|
|
|
|
|
|
inputValidator: (input: string) => Boolean(input && input.trim()),
|
|
|
|
|
|
inputErrorMessage: '请填写下架原因',
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function promptUnpublishApp(id: string) {
|
|
|
|
|
|
const reason = await promptUnpublishReason('下架确认')
|
|
|
|
|
|
if (!reason) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateAdminApi.unpublishAppVersion(id, reason)
|
|
|
|
|
|
ElMessage.success('已下架')
|
|
|
|
|
|
await loadAppVersions()
|
|
|
|
|
|
await loadOperationLogs()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('下架失败')
|
|
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
async function promptUnpublishRn(id: string) {
|
|
|
|
|
|
const reason = await promptUnpublishReason('下架确认')
|
|
|
|
|
|
if (!reason) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateAdminApi.unpublishRnBundle(id, reason)
|
|
|
|
|
|
ElMessage.success('已下架')
|
|
|
|
|
|
await loadRnBundles()
|
|
|
|
|
|
await loadOperationLogs()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('下架失败')
|
|
|
|
|
|
}
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-08 18:32:00 +08:00
|
|
|
|
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reviewTagType(state: string): string {
|
2026-05-08 18:32:00 +08:00
|
|
|
|
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
2026-04-29 00:36:41 +08:00
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
function operationResourceLabel(resourceType: string) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
APP_VERSION: 'App 版本',
|
|
|
|
|
|
RN_BUNDLE: 'RN Bundle',
|
|
|
|
|
|
}[resourceType] ?? resourceType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function operationActionLabel(action: string) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
UPLOAD: '上传',
|
|
|
|
|
|
PUBLISH: '发布',
|
|
|
|
|
|
REPUBLISH: '重新上架',
|
|
|
|
|
|
SCHEDULE_PUBLISH: '定时发布',
|
|
|
|
|
|
UPDATE_FORCE: '修改强更',
|
|
|
|
|
|
SAVE_DRAFT: '保存草稿',
|
|
|
|
|
|
UNPUBLISH: '下架',
|
|
|
|
|
|
STORE_SUBMIT: '提交市场',
|
2026-05-08 18:32:00 +08:00
|
|
|
|
STORE_SUBMIT_REQUEST: '提交市场请求',
|
|
|
|
|
|
STORE_SUBMIT_BATCH_START: '市场提交开始',
|
|
|
|
|
|
STORE_SUBMIT_BATCH_END: '市场提交结束',
|
|
|
|
|
|
STORE_SUBMIT_BATCH_FAILED: '市场提交失败',
|
|
|
|
|
|
STORE_SUBMIT_BATCH_SKIPPED: '市场提交跳过',
|
|
|
|
|
|
STORE_SUBMIT_STORE_START: '市场提交开始',
|
|
|
|
|
|
STORE_SUBMIT_STORE_STAGE: '市场提交阶段',
|
|
|
|
|
|
STORE_SUBMIT_STORE_SUCCESS: '市场提交成功',
|
|
|
|
|
|
STORE_SUBMIT_STORE_FAILED: '市场提交失败',
|
2026-04-30 09:49:05 +08:00
|
|
|
|
STORE_REVIEW: '审核回写',
|
|
|
|
|
|
GRAY_UPDATE: '灰度配置',
|
|
|
|
|
|
AUTO_PUBLISH: '自动发布',
|
|
|
|
|
|
CREATE_STORE_CONFIG: '创建商店配置',
|
|
|
|
|
|
UPDATE_STORE_CONFIG: '更新商店配置',
|
|
|
|
|
|
DELETE_STORE_CONFIG: '删除商店配置',
|
|
|
|
|
|
}[action] ?? action
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDetail(detailJson?: string) {
|
|
|
|
|
|
if (!detailJson) return '-'
|
|
|
|
|
|
try {
|
|
|
|
|
|
const value = JSON.parse(detailJson) as Record<string, unknown>
|
|
|
|
|
|
return Object.entries(value)
|
|
|
|
|
|
.map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : String(val)}`)
|
|
|
|
|
|
.join(';')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return detailJson
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateViewport() {
|
|
|
|
|
|
isMobile.value = window.innerWidth < 768
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOperationLogs() {
|
|
|
|
|
|
loadingOperationLogs.value = true
|
|
|
|
|
|
try {
|
2026-05-07 19:39:47 +08:00
|
|
|
|
const res = await updateAdminApi.listOperationLogs(appKey, 100)
|
2026-04-30 09:49:05 +08:00
|
|
|
|
operationLogs.value = res.data.data
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
operationLogs.value = []
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingOperationLogs.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
function scheduleStoreReviewReload() {
|
|
|
|
|
|
if (storeReviewReloadTimer) {
|
|
|
|
|
|
clearTimeout(storeReviewReloadTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
storeReviewReloadTimer = setTimeout(() => {
|
|
|
|
|
|
void loadAppVersions()
|
|
|
|
|
|
void loadOperationLogs()
|
|
|
|
|
|
}, 200)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseStoreReview(json?: string): { store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[] {
|
2026-04-29 00:36:41 +08:00
|
|
|
|
if (!json) return []
|
|
|
|
|
|
try {
|
2026-04-30 09:49:05 +08:00
|
|
|
|
const m = JSON.parse(json) as Record<string, unknown>
|
|
|
|
|
|
return Object.entries(m).map(([store, value]) => {
|
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
|
return { store, state: value, reason: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value && typeof value === 'object') {
|
|
|
|
|
|
const item = value as Record<string, unknown>
|
|
|
|
|
|
return {
|
|
|
|
|
|
store,
|
|
|
|
|
|
state: String(item.state ?? ''),
|
|
|
|
|
|
reason: String(item.reason ?? ''),
|
2026-05-08 18:32:00 +08:00
|
|
|
|
stage: String(item.stage ?? ''),
|
|
|
|
|
|
submittedAt: String(item.submittedAt ?? ''),
|
|
|
|
|
|
updatedAt: String(item.updatedAt ?? ''),
|
|
|
|
|
|
batchId: String(item.batchId ?? ''),
|
2026-04-30 09:49:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { store, state: String(value ?? ''), reason: '' }
|
|
|
|
|
|
})
|
2026-04-29 12:33:26 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
watch(app, (value) => {
|
|
|
|
|
|
if (value?.packageName) {
|
|
|
|
|
|
appUploadForm.value.packageName = value.packageName
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
watch(storeConfigs, () => {
|
|
|
|
|
|
if (appUploadForm.value.platform === 'IOS') {
|
|
|
|
|
|
appUploadForm.value.appStoreUrl = getStoreJumpUrl('APP_STORE') || appUploadForm.value.appStoreUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appUploadForm.value.platform === 'HARMONY') {
|
|
|
|
|
|
appUploadForm.value.marketUrl = getStoreJumpUrl('HARMONY_APP') || appUploadForm.value.marketUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
watch([grayMemberKeyword, grayMemberGroupFilter], () => {
|
|
|
|
|
|
if (showGray.value && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL') {
|
|
|
|
|
|
loadGrayMembers()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(grayForm, () => {
|
|
|
|
|
|
if (showGray.value && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL') {
|
|
|
|
|
|
loadGrayMembers()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { deep: true })
|
2026-04-29 15:46:40 +08:00
|
|
|
|
|
2026-04-24 16:16:54 +08:00
|
|
|
|
onMounted(() => {
|
2026-04-30 09:49:05 +08:00
|
|
|
|
updateViewport()
|
|
|
|
|
|
window.addEventListener('resize', updateViewport)
|
2026-05-07 13:53:02 +08:00
|
|
|
|
if (isServicesPortal.value) {
|
|
|
|
|
|
appApi.list().then(res => { portalApps.value = res.data.data })
|
2026-05-07 19:39:47 +08:00
|
|
|
|
if (!appKey) return
|
2026-05-07 13:53:02 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
loadApp()
|
2026-04-24 16:16:54 +08:00
|
|
|
|
loadAppVersions()
|
|
|
|
|
|
loadRnBundles()
|
2026-04-29 00:36:41 +08:00
|
|
|
|
loadStoreConfigs()
|
2026-04-29 19:08:13 +08:00
|
|
|
|
loadPublishConfig()
|
2026-04-30 09:49:05 +08:00
|
|
|
|
loadOperationLogs()
|
2026-05-08 18:32:00 +08:00
|
|
|
|
void connectStoreReviewRealtime(appKey, () => {
|
|
|
|
|
|
scheduleStoreReviewReload()
|
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
if (import.meta.env.DEV) {
|
|
|
|
|
|
console.warn('[tenant-platform] store review realtime unavailable', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-30 09:49:05 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('resize', updateViewport)
|
2026-05-08 18:32:00 +08:00
|
|
|
|
disconnectStoreReviewRealtime()
|
|
|
|
|
|
if (storeReviewReloadTimer) {
|
|
|
|
|
|
clearTimeout(storeReviewReloadTimer)
|
|
|
|
|
|
storeReviewReloadTimer = null
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-04-30 09:49:05 +08:00
|
|
|
|
.toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.responsive-toolbar {
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
.table-wrap {
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
}
|
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-form {
|
|
|
|
|
|
min-width: 0;
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
.release-config-form {
|
|
|
|
|
|
max-width: 920px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-card {
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-card-title-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-card-title {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-card-subtitle {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.guide-image {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 160px;
|
|
|
|
|
|
margin: 8px 0 12px;
|
|
|
|
|
|
border-radius: 6px;
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
.guide-link-title {
|
2026-04-28 21:05:07 +08:00
|
|
|
|
font-weight: 600;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
font-size: 13px;
|
2026-04-29 12:33:26 +08:00
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
.guide-link-hint {
|
2026-04-29 12:33:26 +08:00
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
font-size: 13px;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
margin-bottom: 8px;
|
2026-04-29 12:33:26 +08:00
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
|
|
|
|
|
.guide-hint {
|
2026-04-29 12:33:26 +08:00
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
2026-04-29 19:08:13 +08:00
|
|
|
|
|
2026-05-08 10:22:39 +08:00
|
|
|
|
.apk-dropzone {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apk-dropzone :deep(.el-upload) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apk-dropzone :deep(.el-upload-dragger) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apk-dropzone :deep(.el-upload__text) {
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apk-dropzone :deep(.el-upload__tip) {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 10:25:50 +08:00
|
|
|
|
.bundle-dropzone {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bundle-dropzone :deep(.el-upload) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bundle-dropzone :deep(.el-upload-dragger) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bundle-dropzone :deep(.el-upload__text) {
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bundle-dropzone :deep(.el-upload__tip) {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
.gray-member-groups {
|
2026-04-29 12:33:26 +08:00
|
|
|
|
width: 100%;
|
2026-04-28 21:05:07 +08:00
|
|
|
|
}
|
2026-04-29 17:35:52 +08:00
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
.gray-group-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 19:08:13 +08:00
|
|
|
|
.gray-member-row {
|
|
|
|
|
|
padding: 6px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gray-member-id {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gray-member-name {
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 11:47:01 +08:00
|
|
|
|
.upload-progress-block {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-progress-text {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 17:35:52 +08:00
|
|
|
|
.release-store-checkbox-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-04-29 19:08:13 +08:00
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 8px;
|
2026-04-29 17:35:52 +08:00
|
|
|
|
}
|
2026-04-30 09:49:05 +08:00
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
|
.responsive-toolbar {
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.responsive-toolbar :deep(.el-radio-group),
|
|
|
|
|
|
.responsive-toolbar :deep(.el-button) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.responsive-toolbar :deep(.el-radio-group) {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.responsive-toolbar :deep(.el-radio-button) {
|
|
|
|
|
|
flex: 1 1 32%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table-wrap :deep(.el-table) {
|
|
|
|
|
|
min-width: 980px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 18:32:00 +08:00
|
|
|
|
.store-review-cell {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.store-review-tags {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.store-review-detail-btn {
|
|
|
|
|
|
align-self: flex-start;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
min-height: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
|
.store-grid,
|
|
|
|
|
|
.guide-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.release-config-form :deep(.el-form-item__content) {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.release-store-checkbox-row {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.release-store-checkbox-row :deep(.el-tag) {
|
|
|
|
|
|
margin-left: 0 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-dialog) {
|
|
|
|
|
|
margin: 8px auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-07 13:53:02 +08:00
|
|
|
|
|
|
|
|
|
|
.portal-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-bar-title {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
2026-05-08 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
.drag-overlay {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.55);
|
|
|
|
|
|
z-index: 3000;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
backdrop-filter: blur(2px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drag-overlay-content {
|
|
|
|
|
|
background: var(--el-bg-color);
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
padding: 48px 64px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drag-overlay-title {
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drag-overlay-hint {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
}
|
2026-04-24 16:16:54 +08:00
|
|
|
|
</style>
|