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

817 行
26 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<section class="store-monitoring">
<el-alert
title="这里只监测 Apple App Store 与鸿蒙应用市场的官方状态,不会上传安装包、提交审核或执行发布。当前仅判断中国大陆CHN是否可下载。"
type="info"
show-icon
:closable="false"
/>
<div class="section-heading">
<div>
<h3>商店绑定</h3>
<p>监测凭据加密保存页面只展示是否已配置不会回显密钥正文</p>
</div>
<el-button :loading="loadingBindings" @click="loadBindings">刷新</el-button>
</div>
<div class="binding-grid">
<el-card
v-for="definition in STORE_DEFINITIONS"
:key="definition.storeType"
shadow="never"
class="binding-card"
>
<template #header>
<div class="card-header">
<div>
<strong>{{ definition.label }}</strong>
<div class="card-subtitle">{{ definition.platformLabel }} · 中国大陆</div>
</div>
<el-tag :type="bindingFor(definition.storeType)?.enabled ? 'success' : 'info'">
{{ bindingFor(definition.storeType)?.enabled ? '监测中' : '未启用' }}
</el-tag>
</div>
</template>
<template v-if="bindingFor(definition.storeType)">
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="应用标识">
{{ bindingFor(definition.storeType)?.packageIdentifier }}
</el-descriptions-item>
<el-descriptions-item label="官方应用 ID">
{{ bindingFor(definition.storeType)?.officialAppId || '同步后识别' }}
</el-descriptions-item>
<el-descriptions-item label="监测凭据">
<el-tag
:type="bindingFor(definition.storeType)?.monitorCredentialConfigured ? 'success' : 'danger'"
size="small"
>
{{ bindingFor(definition.storeType)?.monitorCredentialConfigured ? '已配置' : '未配置' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">
{{ displayTime(bindingFor(definition.storeType)?.lastSyncedAt) }}
</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="bindingFor(definition.storeType)?.lastSyncStatus === 'FAILED'"
:title="bindingFor(definition.storeType)?.lastSyncError || '最近一次同步失败'"
type="error"
show-icon
:closable="false"
class="sync-error"
/>
</template>
<el-empty v-else description="尚未绑定" :image-size="56" />
<div class="card-actions">
<el-button @click="openBindingDialog(definition.storeType)">
{{ bindingFor(definition.storeType) ? '修改绑定' : '创建绑定' }}
</el-button>
<el-button
type="primary"
plain
:disabled="!bindingFor(definition.storeType)?.enabled"
:loading="syncingStore === definition.storeType"
@click="synchronize(definition.storeType)"
>
手动同步
</el-button>
<el-button
:disabled="!bindingFor(definition.storeType)"
@click="selectBinding(definition.storeType)"
>
查看监测结果
</el-button>
</div>
</el-card>
</div>
<template v-if="selectedBinding">
<div class="section-heading result-heading">
<div>
<h3>{{ storeLabel(selectedBinding.storeType) }}监测结果</h3>
<p>“已在中国大陆上架”只在官方库存确认 CHN 可下载时显示。</p>
</div>
</div>
<el-tabs v-model="resultTab" @tab-change="handleResultTabChange">
<el-tab-pane label="版本状态" name="versions">
<el-table :data="versions" v-loading="loadingVersions" border stripe>
<el-table-column prop="versionName" label="版本" min-width="110" />
<el-table-column prop="buildVersion" label="构建号" min-width="110">
<template #default="{ row }">{{ row.buildVersion || '—' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="170">
<template #default="{ row }">
<el-tag :type="stateTagType(row.state)">{{ stateLabel(row.state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="中国大陆" min-width="145">
<template #default="{ row }">
<el-tag :type="row.chinaAvailable ? 'success' : 'info'">
{{ row.chinaAvailable ? '已可下载' : '尚不可下载' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rawState" label="官方原始状态" min-width="170">
<template #default="{ row }">{{ row.rawState || '—' }}</template>
</el-table-column>
<el-table-column label="状态时间" min-width="170">
<template #default="{ row }">{{ displayTime(row.stateOccurredAt) }}</template>
</el-table-column>
<el-table-column label="最近同步" min-width="170">
<template #default="{ row }">{{ displayTime(row.lastSyncedAt) }}</template>
</el-table-column>
<el-table-column label="Xuqm 更新草稿" min-width="245" fixed="right">
<template #default="{ row }">
<div v-if="draftResults[row.id]" class="draft-result">
<el-tag
:type="draftResults[row.id].publishStatus === 'DRAFT' ? 'success' : 'info'"
size="small"
>
{{ draftResults[row.id].publishStatus }}
</el-tag>
<span>AppVersion ID{{ draftResults[row.id].appVersionId }}</span>
</div>
<el-button
v-else-if="canCreateUpdateDraft(row)"
link
type="primary"
:loading="creatingDraftVersionId === row.id"
:disabled="creatingDraftVersionId === row.id"
@click="createUpdateDraft(row)"
>
创建 Xuqm 更新草稿
</el-button>
<span v-else class="ineligible-draft">上架且 CHN 可下载后可创建</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingVersions && versions.length === 0" description="暂无官方版本记录" />
<el-alert
title="此操作在尚未关联时只创建 Xuqm 版本管理中的 DRAFT 草稿,不会向 Apple 或鸿蒙应用市场提交审核,也不会发布更新。"
type="warning"
:closable="false"
show-icon
class="draft-hint"
/>
</el-tab-pane>
<el-tab-pane label="事件时间线" name="events">
<div v-loading="loadingEvents" class="timeline-wrap">
<el-timeline v-if="events.length">
<el-timeline-item
v-for="event in events"
:key="event.id"
:timestamp="displayTime(event.occurredAt || event.receivedAt)"
placement="top"
:type="stateTagType(event.currentState)"
>
<el-card shadow="never">
<div class="event-title">{{ eventLabel(event.eventType) }}</div>
<div class="event-state">
<template v-if="event.previousState">
{{ stateLabel(event.previousState) }} →
</template>
{{ stateLabel(event.currentState) }}
</div>
<div class="event-meta">
来源:{{ sourceLabel(event.source) }}
<span v-if="event.rawState"> · 官方状态:{{ event.rawState }}</span>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else-if="!loadingEvents" description="暂无状态事件" />
</div>
</el-tab-pane>
</el-tabs>
</template>
<el-divider />
<div class="section-heading">
<div>
<h3>状态通知</h3>
<p>邮件和 Webhook 默认关闭;即使通知关闭,商店状态事件仍会保留在时间线中。</p>
</div>
<el-button
type="primary"
:loading="savingNotification"
@click="saveNotificationPolicy"
>
保存通知策略
</el-button>
</div>
<el-form
v-loading="loadingNotification"
:model="notificationForm"
label-width="150px"
class="notification-form"
>
<el-form-item label="邮件通知">
<el-switch v-model="notificationForm.emailEnabled" />
<span class="form-tip">默认关闭</span>
</el-form-item>
<el-form-item v-if="notificationForm.emailEnabled" label="收件人">
<el-select
v-model="notificationForm.emailRecipients"
multiple
filterable
allow-create
default-first-option
placeholder="输入邮箱后按回车,可添加多个"
class="full-width"
/>
</el-form-item>
<el-form-item label="Webhook 通知">
<el-switch v-model="notificationForm.webhookEnabled" />
<span class="form-tip">默认关闭,启用后请求包含事件 ID 与签名</span>
</el-form-item>
<template v-if="notificationForm.webhookEnabled">
<el-form-item label="Webhook 地址">
<el-input
v-model.trim="notificationForm.webhookUrl"
placeholder="https://example.com/hooks/xuqm-store"
/>
</el-form-item>
<el-form-item label="签名密钥">
<el-input
v-model="notificationForm.webhookSecret"
type="password"
show-password
autocomplete="new-password"
:placeholder="notificationForm.webhookSecretConfigured ? '已配置;留空表示不变' : '首次启用必须填写'"
/>
<span class="form-tip">密钥只写入、不回显。</span>
</el-form-item>
</template>
<el-form-item label="通知事件">
<el-checkbox-group v-model="notificationForm.eventTypes">
<el-checkbox value="STORE_VERSION_DISCOVERED">发现新版本</el-checkbox>
<el-checkbox value="STORE_STATE_CHANGED">版本状态变化</el-checkbox>
</el-checkbox-group>
<div class="event-tip">不选择表示订阅全部商店状态事件。</div>
</el-form-item>
</el-form>
<el-dialog
v-model="bindingDialogVisible"
:title="`${bindingFor(bindingForm.storeType) ? '修改' : '创建'}${storeLabel(bindingForm.storeType)}监测绑定`"
width="620px"
:close-on-click-modal="false"
>
<el-alert
v-if="bindingFor(bindingForm.storeType)?.monitorCredentialConfigured"
title="监测凭据已经配置。密钥字段全部留空时保留现有凭据;填写后将整体替换。"
type="info"
:closable="false"
show-icon
class="dialog-alert"
/>
<el-form :model="bindingForm" label-width="140px">
<el-form-item label="应用标识" required>
<el-input
v-model.trim="bindingForm.packageIdentifier"
:placeholder="bindingForm.storeType === 'APP_STORE' ? 'iOS Bundle ID' : '鸿蒙 Bundle Name'"
/>
</el-form-item>
<el-form-item label="应用市场地址">
<el-input v-model.trim="bindingForm.marketUrl" placeholder="可选,只用于记录跳转地址" />
</el-form-item>
<el-form-item label="启用定时监测">
<el-switch v-model="bindingForm.enabled" />
</el-form-item>
<el-form-item label="凭据名称">
<el-input v-model.trim="bindingForm.credentialDisplayName" placeholder="例如:生产只读监测凭据" />
</el-form-item>
<template v-if="bindingForm.storeType === 'APP_STORE'">
<el-form-item label="Issuer ID">
<el-input v-model.trim="bindingForm.issuerId" autocomplete="off" />
</el-form-item>
<el-form-item label="Key ID">
<el-input v-model.trim="bindingForm.keyId" autocomplete="off" />
</el-form-item>
<el-form-item label=".p8 私钥">
<el-input
v-model="bindingForm.privateKey"
type="textarea"
:rows="6"
autocomplete="off"
placeholder="粘贴包含 BEGIN/END 标记的完整 PEM 内容"
/>
</el-form-item>
</template>
<template v-else>
<el-form-item label="Client ID">
<el-input v-model.trim="bindingForm.clientId" autocomplete="off" />
</el-form-item>
<el-form-item label="Client Secret">
<el-input
v-model="bindingForm.clientSecret"
type="password"
show-password
autocomplete="new-password"
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="bindingDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingBinding" @click="saveBinding">保存绑定</el-button>
</template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
updateAdminApi,
type StoreMonitoringBinding,
type StoreMonitoringEvent,
type StoreMonitoringType,
type StoreMonitoringVersion,
type StoreUpdateDraft,
type StoreVersionState,
} from '@/api/update'
import { formatTime } from '@/utils/date'
const props = defineProps<{ appKey: string }>()
const STORE_DEFINITIONS: ReadonlyArray<{
storeType: StoreMonitoringType
label: string
platformLabel: string
}> = [
{ storeType: 'APP_STORE', label: 'Apple App Store', platformLabel: 'iOS' },
{ storeType: 'HARMONY_APP', label: '鸿蒙应用市场', platformLabel: 'HarmonyOS' },
]
const bindings = ref<StoreMonitoringBinding[]>([])
const loadingBindings = ref(false)
const syncingStore = ref<StoreMonitoringType>()
const selectedBinding = ref<StoreMonitoringBinding>()
const versions = ref<StoreMonitoringVersion[]>([])
const events = ref<StoreMonitoringEvent[]>([])
const resultTab = ref<'versions' | 'events'>('versions')
const loadingVersions = ref(false)
const loadingEvents = ref(false)
const creatingDraftVersionId = ref<string>()
const draftResults = reactive<Record<string, StoreUpdateDraft>>({})
const bindingDialogVisible = ref(false)
const savingBinding = ref(false)
const bindingForm = reactive({
storeType: 'APP_STORE' as StoreMonitoringType,
packageIdentifier: '',
marketUrl: '',
enabled: true,
credentialDisplayName: '',
issuerId: '',
keyId: '',
privateKey: '',
clientId: '',
clientSecret: '',
})
const loadingNotification = ref(false)
const savingNotification = ref(false)
const notificationForm = reactive({
emailEnabled: false,
emailRecipients: [] as string[],
webhookEnabled: false,
webhookUrl: '',
webhookSecret: '',
webhookSecretConfigured: false,
eventTypes: [] as string[],
})
function bindingFor(storeType: StoreMonitoringType) {
return bindings.value.find(binding => binding.storeType === storeType)
}
function storeLabel(storeType?: StoreMonitoringType) {
return STORE_DEFINITIONS.find(item => item.storeType === storeType)?.label ?? '应用商店'
}
function displayTime(value?: string) {
return value ? formatTime(value) : '—'
}
function stateLabel(state?: StoreVersionState) {
const labels: Partial<Record<StoreVersionState, string>> = {
DISCOVERED: '已发现',
PREPARING: '准备中',
SUBMITTED: '已提交',
WAITING_FOR_REVIEW: '等待审核',
IN_REVIEW: '审核中',
REJECTED: '审核被拒',
APPROVED_PENDING_RELEASE: '已审核待发布',
RELEASE_SCHEDULED: '已安排发布',
RELEASE_REQUESTED: '已请求发布',
PROCESSING_DISTRIBUTION: '分发处理中',
PARTIALLY_AVAILABLE: '部分地区可用',
AVAILABLE: '已上架',
WITHDRAWN: '已撤回',
REMOVED: '已下架',
UNKNOWN: '未知',
SYNC_FAILED: '同步失败',
}
return state ? labels[state] ?? state : '—'
}
function stateTagType(state?: StoreVersionState) {
if (state === 'AVAILABLE') return 'success'
if (state === 'REJECTED' || state === 'REMOVED' || state === 'SYNC_FAILED') return 'danger'
if (state === 'IN_REVIEW' || state === 'WAITING_FOR_REVIEW' || state === 'APPROVED_PENDING_RELEASE') {
return 'warning'
}
return 'info'
}
function sourceLabel(source: StoreMonitoringEvent['source']) {
if (source === 'WEBHOOK') return '官方回调'
if (source === 'MANUAL') return '人工操作'
if (source === 'IMPORT') return '历史迁移'
return '定时轮询'
}
function eventLabel(eventType: string) {
if (eventType === 'STORE_VERSION_DISCOVERED') return '发现商店版本'
if (eventType === 'STORE_STATE_CHANGED') return '商店状态变化'
return eventType
}
async function loadBindings() {
if (!props.appKey) return
loadingBindings.value = true
try {
const response = await updateAdminApi.listStoreMonitoringBindings(props.appKey)
bindings.value = response.data.data
if (selectedBinding.value) {
selectedBinding.value = bindingFor(selectedBinding.value.storeType)
if (selectedBinding.value) await loadVersions()
}
} finally {
loadingBindings.value = false
}
}
function clearCredentialFields() {
bindingForm.issuerId = ''
bindingForm.keyId = ''
bindingForm.privateKey = ''
bindingForm.clientId = ''
bindingForm.clientSecret = ''
}
function openBindingDialog(storeType: StoreMonitoringType) {
const binding = bindingFor(storeType)
bindingForm.storeType = storeType
bindingForm.packageIdentifier = binding?.packageIdentifier ?? ''
bindingForm.marketUrl = binding?.marketUrl ?? ''
bindingForm.enabled = binding?.enabled ?? true
bindingForm.credentialDisplayName = ''
clearCredentialFields()
bindingDialogVisible.value = true
}
function buildCredential() {
if (bindingForm.storeType === 'APP_STORE') {
const anyFilled = bindingForm.issuerId || bindingForm.keyId || bindingForm.privateKey
if (!anyFilled) return undefined
if (!bindingForm.issuerId || !bindingForm.keyId || !bindingForm.privateKey) {
throw new Error('替换 App Store 凭据时必须同时填写 Issuer ID、Key ID 和 .p8 私钥')
}
return {
issuerId: bindingForm.issuerId,
keyId: bindingForm.keyId,
privateKey: bindingForm.privateKey,
}
}
const anyFilled = bindingForm.clientId || bindingForm.clientSecret
if (!anyFilled) return undefined
if (!bindingForm.clientId || !bindingForm.clientSecret) {
throw new Error('替换鸿蒙监测凭据时必须同时填写 Client ID 和 Client Secret')
}
return {
clientId: bindingForm.clientId,
clientSecret: bindingForm.clientSecret,
}
}
async function saveBinding() {
if (!bindingForm.packageIdentifier) {
ElMessage.warning('请填写应用标识')
return
}
savingBinding.value = true
try {
const credential = buildCredential()
if (!bindingFor(bindingForm.storeType) && !credential) {
ElMessage.warning('首次绑定必须填写完整的监测凭据')
return
}
await updateAdminApi.saveStoreMonitoringBinding(props.appKey, bindingForm.storeType, {
packageIdentifier: bindingForm.packageIdentifier,
marketUrl: bindingForm.marketUrl || undefined,
enabled: bindingForm.enabled,
credentialDisplayName: bindingForm.credentialDisplayName || undefined,
...(credential ? { credential } : {}),
})
bindingDialogVisible.value = false
clearCredentialFields()
await loadBindings()
ElMessage.success('商店监测绑定已保存')
} catch (error) {
if (error instanceof Error && !('response' in error)) ElMessage.error(error.message)
} finally {
savingBinding.value = false
}
}
async function synchronize(storeType: StoreMonitoringType) {
syncingStore.value = storeType
try {
await updateAdminApi.synchronizeStoreMonitoring(props.appKey, storeType)
await loadBindings()
await selectBinding(storeType)
ElMessage.success('已完成官方商店状态同步')
} finally {
syncingStore.value = undefined
}
}
async function selectBinding(storeType: StoreMonitoringType) {
selectedBinding.value = bindingFor(storeType)
resultTab.value = 'versions'
await loadVersions()
}
async function loadVersions() {
if (!selectedBinding.value) return
loadingVersions.value = true
try {
const response = await updateAdminApi.listStoreMonitoringVersions(
props.appKey,
selectedBinding.value.id,
)
versions.value = response.data.data
} finally {
loadingVersions.value = false
}
}
async function loadEvents() {
if (!selectedBinding.value) return
loadingEvents.value = true
try {
const response = await updateAdminApi.listStoreMonitoringEvents(
props.appKey,
selectedBinding.value.id,
)
events.value = response.data.data
} finally {
loadingEvents.value = false
}
}
function canCreateUpdateDraft(version: StoreMonitoringVersion) {
return version.state === 'AVAILABLE' && version.chinaAvailable
}
async function createUpdateDraft(version: StoreMonitoringVersion) {
if (!selectedBinding.value || !canCreateUpdateDraft(version) || draftResults[version.id]) return
creatingDraftVersionId.value = version.id
try {
const response = await updateAdminApi.createStoreUpdateDraft(
props.appKey,
selectedBinding.value.id,
version.id,
)
draftResults[version.id] = response.data.data
const result = response.data.data
ElMessage.success(
result.publishStatus === 'DRAFT'
? `已创建 Xuqm 更新草稿:${result.appVersionId}`
: `该商店版本已关联 Xuqm 版本:${result.appVersionId}`,
)
} finally {
creatingDraftVersionId.value = undefined
}
}
function handleResultTabChange(name: string | number) {
if (name === 'events') void loadEvents()
}
async function loadNotificationPolicy() {
if (!props.appKey) return
loadingNotification.value = true
try {
const response = await updateAdminApi.getStoreNotificationPolicy(props.appKey)
const policy = response.data.data
notificationForm.emailEnabled = policy.emailEnabled
notificationForm.emailRecipients = [...policy.emailRecipients]
notificationForm.webhookEnabled = policy.webhookEnabled
notificationForm.webhookUrl = policy.webhookUrl ?? ''
notificationForm.webhookSecret = ''
notificationForm.webhookSecretConfigured = policy.webhookSecretConfigured
notificationForm.eventTypes = [...policy.eventTypes]
} finally {
loadingNotification.value = false
}
}
async function saveNotificationPolicy() {
if (notificationForm.emailEnabled && notificationForm.emailRecipients.length === 0) {
ElMessage.warning('启用邮件通知时至少添加一个收件人')
return
}
if (notificationForm.webhookEnabled && !notificationForm.webhookUrl) {
ElMessage.warning('启用 Webhook 通知时必须填写地址')
return
}
if (
notificationForm.webhookEnabled &&
!notificationForm.webhookSecretConfigured &&
!notificationForm.webhookSecret
) {
ElMessage.warning('首次启用 Webhook 通知时必须填写签名密钥')
return
}
savingNotification.value = true
try {
const response = await updateAdminApi.saveStoreNotificationPolicy(props.appKey, {
emailEnabled: notificationForm.emailEnabled,
emailRecipients: notificationForm.emailRecipients,
webhookEnabled: notificationForm.webhookEnabled,
webhookUrl: notificationForm.webhookUrl || undefined,
webhookSecret: notificationForm.webhookSecret || undefined,
eventTypes: notificationForm.eventTypes,
})
notificationForm.webhookSecret = ''
notificationForm.webhookSecretConfigured = response.data.data.webhookSecretConfigured
notificationForm.eventTypes = [...response.data.data.eventTypes]
ElMessage.success('通知策略已保存')
} finally {
savingNotification.value = false
}
}
async function initialize() {
await Promise.all([loadBindings(), loadNotificationPolicy()])
}
watch(() => props.appKey, () => {
selectedBinding.value = undefined
versions.value = []
events.value = []
void initialize()
})
onMounted(() => void initialize())
</script>
<style scoped>
.store-monitoring {
min-width: 0;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 22px 0 14px;
}
.section-heading h3 {
margin: 0;
font-size: 17px;
}
.section-heading p {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.binding-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.binding-card {
min-width: 0;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-subtitle,
.event-meta,
.form-tip,
.event-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.card-subtitle {
margin-top: 4px;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.card-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.sync-error,
.dialog-alert {
margin-top: 12px;
}
.result-heading {
margin-top: 28px;
}
.timeline-wrap {
min-height: 140px;
padding: 12px 6px 0;
}
.event-title {
font-weight: 600;
}
.event-state {
margin-top: 6px;
}
.event-meta {
margin-top: 6px;
}
.draft-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
font-size: 12px;
word-break: break-all;
}
.ineligible-draft {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.draft-hint {
margin-top: 14px;
}
.notification-form {
max-width: 820px;
}
.full-width {
width: 100%;
}
.form-tip {
margin-left: 10px;
}
.event-tip {
width: 100%;
margin-top: 4px;
}
@media (max-width: 900px) {
.binding-grid {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
}
}
</style>