一大波改动

这个提交包含在:
XuqmGroup 2026-05-15 16:47:22 +08:00
父节点 1552bfb561
当前提交 a917932a2d
共有 5 个文件被更改,包括 466 次插入1790 次删除

9
package-lock.json 自动生成的
查看文件

@ -1349,6 +1349,14 @@
"resolved": "docs-site", "resolved": "docs-site",
"link": true "link": true
}, },
"node_modules/@xuqm/vue3-sdk": {
"version": "0.2.2",
"resolved": "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.2.tgz",
"integrity": "sha512-1fZrqEcPHf7E7LLIx2QhWG8s/yWxmNa2QNyvhvmsvyp2/LkO0uX++l/4aHZqvfiqlUDhcr892I4YdRoYuzAcQw==",
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@ -4718,6 +4726,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@xuqm/vue3-sdk": "^0.2.2",
"axios": "^1.7.9", "axios": "^1.7.9",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",

查看文件

@ -9,10 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"@xuqm/vue3-sdk": "0.2.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@xuqm/vue3-sdk": "^0.2.2",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
@ -20,9 +20,9 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8",
"unplugin-auto-import": "^0.18.2", "unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4" "unplugin-vue-components": "^0.27.4",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
} }
} }

查看文件

@ -1,5 +1,5 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { init, login, ImClient, type ImMessage } from '@xuqm/vue3-sdk' import { ImClient, type ImMessage } from '@xuqm/vue3-sdk'
import client from '@/api/client' import client from '@/api/client'
export interface StoreReviewRefreshEvent { export interface StoreReviewRefreshEvent {
@ -24,10 +24,6 @@ interface PlatformEventTokenResponse {
let imClient: ImClient | null = null let imClient: ImClient | null = null
let activeAppKey = '' let activeAppKey = ''
function sdkBaseUrl() {
return import.meta.env.VITE_IM_API_BASE_URL ?? ''
}
function sdkWsUrl() { function sdkWsUrl() {
return import.meta.env.VITE_IM_WS_URL ?? '' return import.meta.env.VITE_IM_WS_URL ?? ''
} }
@ -65,22 +61,18 @@ export async function connectStoreReviewRealtime(appKey: string, onEvent: (event
const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', { const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', {
params: { appKey }, params: { appKey },
}) })
const token = res.data.data ?? (res.data as unknown as PlatformEventTokenResponse) const tokenData = res.data.data ?? (res.data as unknown as PlatformEventTokenResponse)
init({ const platformToken = tokenData.token
appKey,
baseUrl: sdkBaseUrl(),
wsUrl: sdkWsUrl(),
debug: import.meta.env.DEV,
})
login(token.userId, token.token)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.debug('[tenant-platform][IM] store review realtime connected token acquired', { console.debug('[tenant-platform][IM] store review realtime token acquired', {
appKey, appKey,
userId: token.userId, userId: tokenData.userId,
}) })
} }
const clientInstance = new ImClient() const wsUrl = sdkWsUrl() || undefined
const clientInstance = new ImClient({ tokenSupplier: () => platformToken, wsUrl })
clientInstance.on('message', (message) => { clientInstance.on('message', (message) => {
const event = parseEvent(message) const event = parseEvent(message)
if (!event || event.appKey !== activeAppKey) return if (!event || event.appKey !== activeAppKey) return

查看文件

@ -495,73 +495,80 @@
</el-dialog> </el-dialog>
<!-- Store Review Detail Dialog --> <!-- Store Review Detail Dialog -->
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="dialogWidth"> <el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'">
<div v-if="storeReviewDetailVersion"> <div v-if="storeReviewDetailVersion">
<el-descriptions :column="1" border style="margin-bottom:16px"> <!-- Version summary bar -->
<el-descriptions-item label="版本"> <div class="review-detail-header">
{{ storeReviewDetailVersion.versionName }} · {{ storeReviewDetailVersion.versionCode }} <span class="review-detail-version">
</el-descriptions-item> {{ storeReviewDetailVersion.versionName }}
<el-descriptions-item label="发布状态"> <span class="review-detail-code">· {{ storeReviewDetailVersion.versionCode }}</span>
<el-tag :type="statusTagType(storeReviewDetailVersion)" size="small"> </span>
{{ statusLabel(storeReviewDetailVersion) }} <el-tag :type="statusTagType(storeReviewDetailVersion)" size="small">
</el-tag> {{ statusLabel(storeReviewDetailVersion) }}
</el-descriptions-item> </el-tag>
<el-descriptions-item label="市场提交目标"> <span v-if="storeReviewDetailLive" class="review-live-dot" title="实时更新中">
<span v-if="parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).length"> <span class="live-pulse" />实时
{{ parseStoreTargets(storeReviewDetailVersion.storeSubmitTargets).map(storeLabel).join('、') }} </span>
</span> </div>
<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> <!-- Per-store cards -->
<el-table-column prop="store" label="市场" width="150"> <div class="store-review-cards">
<template #default="{row}">{{ storeLabel(row.store) }}</template> <div
</el-table-column> v-for="item in storeReviewDetailItems"
<el-table-column prop="state" label="状态" width="120"> :key="item.store"
<template #default="{row}"> class="review-store-card"
<el-tag :type="reviewTagType(row.state)" size="small"> :class="[`review-card--${item.state.toLowerCase()}`, { 'review-card--active': isActiveState(item.state) }]"
{{ reviewLabel(row.state) }} >
<!-- Card header: store name + status -->
<div class="review-card-head">
<span class="review-card-name">{{ storeLabel(item.store) }}</span>
<el-tag :type="reviewTagType(item.state)" size="small" class="review-card-tag">
{{ reviewLabel(item.state) }}
</el-tag> </el-tag>
</template> </div>
</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 <!-- Stage progress -->
v-if="storeReviewDetailItems.some(item => item.state === 'REJECTED')" <div class="review-card-progress">
type="error" <div
show-icon v-for="step in reviewSteps"
:closable="false" :key="step.stage"
style="margin-top:16px" class="review-step"
title="存在审核失败的市场,请查看上方失败原因。" :class="reviewStepClass(item.state, item.stage, step)"
/> >
<div class="review-step-dot">
<el-icon v-if="reviewStepDone(item.state, step)"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="reviewStepActive(item.state, item.stage, step)" class="is-loading"><Loading /></el-icon>
<span v-else class="step-dot-empty" />
</div>
<span class="review-step-label">{{ step.label }}</span>
</div>
</div>
<!-- Meta info -->
<div class="review-card-meta">
<div v-if="item.submittedAt" class="review-card-meta-item">
<span class="meta-label">提交时间</span>
<span class="meta-value">{{ formatTime(item.submittedAt) }}</span>
</div>
<div v-if="item.updatedAt" class="review-card-meta-item">
<span class="meta-label">更新时间</span>
<span class="meta-value">{{ formatTime(item.updatedAt) }}</span>
</div>
<div v-if="item.batchId" class="review-card-meta-item">
<span class="meta-label">批次</span>
<span class="meta-value meta-mono">{{ item.batchId.slice(0, 8) }}</span>
</div>
</div>
<!-- Rejection / withdrawal reason -->
<div v-if="item.reason && (item.state === 'REJECTED' || item.state === 'WITHDRAWN')" class="review-card-reason">
<el-icon><WarningFilled /></el-icon>
<span>{{ item.reason }}</span>
</div>
</div>
</div>
<el-empty v-if="!storeReviewDetailItems.length" description="暂无商店状态数据" />
</div> </div>
<el-empty v-else description="暂无商店状态数据" /> <el-empty v-else description="暂无商店状态数据" />
<template #footer> <template #footer>
@ -762,7 +769,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue' import { CircleCheckFilled, Loading, UploadFilled, WarningFilled } from '@element-plus/icons-vue'
import { appApi, type App } from '@/api/app' import { appApi, type App } from '@/api/app'
import { fileApi } from '@/api/file' import { fileApi } from '@/api/file'
import { import {
@ -1290,6 +1297,7 @@ const submitStoreScheduledAt = ref('')
const showStoreReviewDetail = ref(false) const showStoreReviewDetail = ref(false)
const storeReviewDetailVersion = ref<AppVersion | null>(null) const storeReviewDetailVersion = ref<AppVersion | null>(null)
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([]) const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([])
const storeReviewDetailLive = ref(false)
function openSubmitStoreDialog(row: AppVersion) { function openSubmitStoreDialog(row: AppVersion) {
submitStoreVersion.value = row submitStoreVersion.value = row
@ -1312,6 +1320,7 @@ function parseStoreTargets(json?: string) {
function openStoreReviewDetail(row: AppVersion) { function openStoreReviewDetail(row: AppVersion) {
storeReviewDetailVersion.value = row storeReviewDetailVersion.value = row
storeReviewDetailItems.value = parseStoreReview(row.storeReviewStatus) storeReviewDetailItems.value = parseStoreReview(row.storeReviewStatus)
storeReviewDetailLive.value = false
showStoreReviewDetail.value = true showStoreReviewDetail.value = true
} }
@ -1833,11 +1842,66 @@ function storeLabel(type: string) {
} }
function reviewLabel(state: string): string { function reviewLabel(state: string): string {
return { PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state return {
PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中',
APPROVED: '已通过', REJECTED: '已拒绝', WITHDRAWN: '已撤回',
}[state] ?? state
} }
function reviewTagType(state: string): string { function reviewTagType(state: string): string {
return { PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? '' return {
PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning',
APPROVED: 'success', REJECTED: 'danger', WITHDRAWN: '',
}[state] ?? ''
}
function isActiveState(state: string) {
return state === 'PENDING' || state === 'SUBMITTING' || state === 'UNDER_REVIEW'
}
// Step definitions for per-store progress
const reviewSteps = [
{ stage: 'QUEUED', label: '排队' },
{ stage: 'SUBMITTING', label: '上传' },
{ stage: 'SUBMITTED', label: '审核中' },
{ stage: 'DONE', label: '完成' },
]
const STAGE_ORDER: Record<string, number> = {
QUEUED: 0, SUBMITTING: 1, SUBMITTED: 2,
APPROVED: 3, FAILED: 3, WITHDRAWN: 3, DONE: 3,
}
function stageIndex(state: string, stage: string): number {
if (state === 'APPROVED' || state === 'REJECTED' || state === 'WITHDRAWN') return 3
if (stage) return STAGE_ORDER[stage] ?? 0
return { PENDING: 0, SUBMITTING: 1, UNDER_REVIEW: 2 }[state] ?? 0
}
function reviewStepDone(state: string, step: { stage: string }): boolean {
if (step.stage === 'DONE') return state === 'APPROVED'
const si = stageIndex(state, '')
const ti = STAGE_ORDER[step.stage] ?? 0
if (state === 'APPROVED') return true
if (state === 'REJECTED') return ti < 3
if (state === 'WITHDRAWN') return false
return ti < si
}
function reviewStepActive(state: string, stage: string, step: { stage: string }): boolean {
if (step.stage === 'DONE') return false
const cur = stageIndex(state, stage)
const ti = STAGE_ORDER[step.stage] ?? 0
return ti === cur && isActiveState(state)
}
function reviewStepClass(state: string, stage: string, step: { stage: string }): Record<string, boolean> {
return {
'step--done': reviewStepDone(state, step),
'step--active': reviewStepActive(state, stage, step),
'step--failed': step.stage === 'DONE' && state === 'REJECTED',
'step--withdrawn': step.stage === 'DONE' && state === 'WITHDRAWN',
}
} }
function operationResourceLabel(resourceType: string) { function operationResourceLabel(resourceType: string) {
@ -1981,14 +2045,43 @@ onMounted(() => {
loadPublishConfig() loadPublishConfig()
loadOperationLogs() loadOperationLogs()
void connectStoreReviewRealtime(appKey, (event: StoreReviewRefreshEvent) => { void connectStoreReviewRealtime(appKey, (event: StoreReviewRefreshEvent) => {
notifyStoreReviewRefresh(event.appKey)
const storeName = storeLabel(event.storeType || '') || event.storeType || '应用市场' const storeName = storeLabel(event.storeType || '') || event.storeType || '应用市场'
const stateName = reviewLabel((event.reviewState || '').toUpperCase()) || event.reviewState || '状态变更' const stateName = reviewLabel((event.reviewState || '').toUpperCase()) || event.reviewState || '状态变更'
ElMessage.success(
event.reviewReason // Patch the open dialog card directly for real-time feel
? `${storeName} 审核状态更新为 ${stateName}${event.reviewReason}` const openVersionId = storeReviewDetailVersion.value?.id
: `${storeName} 审核状态更新为 ${stateName}`, if (showStoreReviewDetail.value && openVersionId && openVersionId === event.versionId && event.storeType) {
) storeReviewDetailLive.value = true
const idx = storeReviewDetailItems.value.findIndex(i => i.store === event.storeType)
const patch = {
store: event.storeType,
state: (event.reviewState || '').toUpperCase(),
stage: event.stage || '',
reason: event.reviewReason || '',
batchId: event.batchId || storeReviewDetailItems.value[idx]?.batchId || '',
submittedAt: storeReviewDetailItems.value[idx]?.submittedAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (idx >= 0) {
storeReviewDetailItems.value[idx] = patch
} else {
storeReviewDetailItems.value.push(patch)
}
}
// Only show toast for non-trivial state transitions
const state = (event.reviewState || '').toUpperCase()
if (state && state !== 'PENDING') {
ElMessage({
message: event.reviewReason
? `${storeName}${stateName}${event.reviewReason}`
: `${storeName}${stateName}`,
type: state === 'REJECTED' || state === 'WITHDRAWN' ? 'error'
: state === 'APPROVED' ? 'success' : 'info',
duration: 4000,
})
}
scheduleStoreReviewReload() scheduleStoreReviewReload()
}).catch((error) => { }).catch((error) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -2311,4 +2404,229 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
/* ── Store review card dialog ─────────────────────────────────── */
.review-detail-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 0 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
margin-bottom: 16px;
flex-wrap: wrap;
}
.review-detail-version {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.review-detail-code {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
.review-live-dot {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--el-color-success);
margin-left: auto;
}
.live-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-success);
animation: live-blink 1.2s ease-in-out infinite;
}
@keyframes live-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.store-review-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.review-store-card {
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 14px;
background: var(--el-bg-color);
transition: box-shadow 0.2s, border-color 0.2s;
}
.review-store-card.review-card--active {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
}
.review-store-card.review-card--approved {
border-color: var(--el-color-success-light-5);
}
.review-store-card.review-card--rejected,
.review-store-card.review-card--withdrawn {
border-color: var(--el-border-color-light);
opacity: 0.85;
}
.review-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.review-card-name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.review-card-tag {
flex-shrink: 0;
}
/* Progress steps */
.review-card-progress {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 12px;
}
.review-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.review-step:not(:last-child)::after {
content: '';
position: absolute;
top: 8px;
left: 50%;
width: 100%;
height: 2px;
background: var(--el-border-color);
z-index: 0;
}
.review-step.step--done:not(:last-child)::after {
background: var(--el-color-success);
}
.review-step.step--active:not(:last-child)::after {
background: var(--el-color-primary-light-5);
}
.review-step-dot {
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
font-size: 14px;
}
.step-dot-empty {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--el-border-color);
background: var(--el-bg-color);
}
.review-step.step--done .review-step-dot {
color: var(--el-color-success);
}
.review-step.step--active .review-step-dot {
color: var(--el-color-primary);
}
.review-step.step--failed .review-step-dot {
color: var(--el-color-danger);
}
.review-step.step--withdrawn .review-step-dot {
color: var(--el-text-color-placeholder);
}
.review-step-label {
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
white-space: nowrap;
}
.review-step.step--done .review-step-label {
color: var(--el-color-success);
}
.review-step.step--active .review-step-label {
color: var(--el-color-primary);
font-weight: 500;
}
.review-step.step--failed .review-step-label {
color: var(--el-color-danger);
}
/* Meta rows */
.review-card-meta {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.review-card-meta-item {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 11px;
}
.meta-label {
color: var(--el-text-color-placeholder);
flex-shrink: 0;
min-width: 38px;
}
.meta-value {
color: var(--el-text-color-regular);
word-break: break-all;
}
.meta-mono {
font-family: monospace;
font-size: 10px;
}
/* Rejection reason */
.review-card-reason {
font-size: 11px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 4px;
padding: 6px 8px;
margin-top: 6px;
line-height: 1.5;
}
</style> </style>

1749
yarn.lock

文件差异内容过多而无法显示 加载差异