feat(tenant-platform): service activation realtime + store review retry

- Add IM real-time notification for service activation approval/rejection
  (LicenseManagementView, PushManagementView, AppDetailView)
- Add retry button for FAILED and REJECTED stores in version review detail
- Refactor storeReviewRealtime to single shared IM connection
- Bump @xuqm/vue3-sdk to 0.2.3 (fixes sendSync crash when SDK uninitialized)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-16 14:27:02 +08:00
父节点 ce05fa4fe3
当前提交 6b891bee92
共有 8 个文件被更改,包括 201 次插入38 次删除

查看文件

@ -20,5 +20,8 @@
"@vue/test-utils": "^2.4.9",
"happy-dom": "^20.9.0",
"vitest": "^4.1.5"
},
"dependencies": {
"@xuqm/vue3-sdk": "0.2.3"
}
}

查看文件

@ -10,7 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@xuqm/vue3-sdk": "^0.2.2",
"@xuqm/vue3-sdk": "^0.2.3",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^3.0.1",

查看文件

@ -1,7 +1,11 @@
import { ElMessage } from 'element-plus'
import { ImClient, type ImMessage } from '@xuqm/vue3-sdk'
import { ImClient, type ImMessage, init as initSdk } from '@xuqm/vue3-sdk'
import client from '@/api/client'
// Initialize the SDK global so ImClient.sendSync() can send the /app/chat.sync frame.
// Only appKey is required; no secret is exposed here.
initSdk({ appKey: 'ak_409e217e4aa14254ad73ad3c' })
export interface StoreReviewRefreshEvent {
event: string
appKey: string
@ -16,46 +20,82 @@ export interface StoreReviewRefreshEvent {
timestamp?: number
}
export interface ServiceActivationRefreshEvent {
event: string
appKey: string
serviceType: string
status: string
reviewNote: string
timestamp?: number
}
interface PlatformEventTokenResponse {
userId: string
token: string
}
// Single shared connection — all platform event types go through one WebSocket.
let imClient: ImClient | null = null
let activeAppKey = ''
let storeReviewHandler: ((event: StoreReviewRefreshEvent) => void) | null = null
let serviceActivationHandler: ((event: ServiceActivationRefreshEvent) => void) | null = null
function sdkWsUrl() {
return import.meta.env.VITE_IM_WS_URL ?? ''
}
function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null {
function parseAnyEvent(message: ImMessage): StoreReviewRefreshEvent | ServiceActivationRefreshEvent | null {
try {
const raw = JSON.parse(message.content) as Record<string, unknown>
const payload = (raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw) as Record<string, unknown>
const event = String(payload.event ?? raw?.event ?? '')
const appKey = String(payload.appKey ?? raw?.appKey ?? '')
if (event !== 'store_review_update' || !appKey) return null
return {
event,
appKey,
versionId: String(payload.versionId ?? raw?.versionId ?? ''),
storeType: String(payload.storeType ?? raw?.storeType ?? ''),
reviewState: String(payload.reviewState ?? raw?.reviewState ?? ''),
reviewReason: String(payload.reviewReason ?? raw?.reviewReason ?? ''),
stage: String(payload.stage ?? raw?.stage ?? ''),
batchId: String(payload.batchId ?? raw?.batchId ?? ''),
publishStatus: String(payload.publishStatus ?? raw?.publishStatus ?? ''),
source: String(payload.source ?? raw?.source ?? ''),
timestamp: Number(payload.timestamp ?? raw?.timestamp ?? Date.now()),
if (!appKey) return null
if (event === 'store_review_update') {
return {
event,
appKey,
versionId: String(payload.versionId ?? raw?.versionId ?? ''),
storeType: String(payload.storeType ?? raw?.storeType ?? ''),
reviewState: String(payload.reviewState ?? raw?.reviewState ?? ''),
reviewReason: String(payload.reviewReason ?? raw?.reviewReason ?? ''),
stage: String(payload.stage ?? raw?.stage ?? ''),
batchId: String(payload.batchId ?? raw?.batchId ?? ''),
publishStatus: String(payload.publishStatus ?? raw?.publishStatus ?? ''),
source: String(payload.source ?? raw?.source ?? ''),
timestamp: Number(payload.timestamp ?? raw?.timestamp ?? Date.now()),
}
}
if (event === 'service_activation_update') {
return {
event,
appKey,
serviceType: String(payload.serviceType ?? raw?.serviceType ?? ''),
status: String(payload.status ?? raw?.status ?? ''),
reviewNote: String(payload.reviewNote ?? raw?.reviewNote ?? ''),
timestamp: Number(payload.timestamp ?? raw?.timestamp ?? Date.now()),
}
}
return null
} catch {
return null
}
}
export async function connectStoreReviewRealtime(appKey: string, onEvent: (event: StoreReviewRefreshEvent) => void) {
if (!appKey) return
disconnectStoreReviewRealtime()
function disconnectAll() {
if (imClient) {
imClient.disconnect()
imClient = null
}
activeAppKey = ''
}
async function ensureConnection(appKey: string) {
if (imClient && activeAppKey === appKey) return
disconnectAll()
activeAppKey = appKey
const res = await client.get<{ data: PlatformEventTokenResponse }>('/im/platform-events/token', {
@ -65,7 +105,7 @@ export async function connectStoreReviewRealtime(appKey: string, onEvent: (event
const platformToken = tokenData.token
if (import.meta.env.DEV) {
console.debug('[tenant-platform][IM] store review realtime token acquired', {
console.debug('[tenant-platform][IM] platform event connection established', {
appKey,
userId: tokenData.userId,
})
@ -74,25 +114,32 @@ export async function connectStoreReviewRealtime(appKey: string, onEvent: (event
const wsUrl = sdkWsUrl() || undefined
const clientInstance = new ImClient({ tokenSupplier: () => platformToken, wsUrl })
clientInstance.on('message', (message) => {
const event = parseEvent(message)
const event = parseAnyEvent(message)
if (!event || event.appKey !== activeAppKey) return
onEvent(event)
if (event.event === 'store_review_update' && storeReviewHandler) {
storeReviewHandler(event as StoreReviewRefreshEvent)
} else if (event.event === 'service_activation_update' && serviceActivationHandler) {
serviceActivationHandler(event as ServiceActivationRefreshEvent)
}
})
clientInstance.on('error', (error) => {
if (import.meta.env.DEV) {
console.warn('[tenant-platform][IM] store review realtime error', error)
console.warn('[tenant-platform][IM] platform event error', error)
}
})
clientInstance.connect()
imClient = clientInstance
}
export async function connectStoreReviewRealtime(appKey: string, onEvent: (event: StoreReviewRefreshEvent) => void) {
if (!appKey) return
storeReviewHandler = onEvent
await ensureConnection(appKey)
}
export function disconnectStoreReviewRealtime() {
if (imClient) {
imClient.disconnect()
imClient = null
}
activeAppKey = ''
storeReviewHandler = null
if (!serviceActivationHandler) disconnectAll()
}
export function notifyStoreReviewRefresh(appKey: string) {
@ -100,3 +147,17 @@ export function notifyStoreReviewRefresh(appKey: string) {
ElMessage.info('检测到审核状态更新,正在刷新...')
}
}
export async function connectServiceActivationRealtime(
appKey: string,
onEvent: (event: ServiceActivationRefreshEvent) => void,
) {
if (!appKey) return
serviceActivationHandler = onEvent
await ensureConnection(appKey)
}
export function disconnectServiceActivationRealtime() {
serviceActivationHandler = null
if (!storeReviewHandler) disconnectAll()
}

查看文件

@ -215,6 +215,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
import { appApi, type App, type FeatureService } from '@/api/app'
import client from '@/api/client'
import {
connectServiceActivationRealtime,
disconnectServiceActivationRealtime,
} from '@/services/storeReviewRealtime'
const route = useRoute()
const app = ref<App | null>(null)
@ -383,13 +387,24 @@ function updateViewport() {
}
onMounted(() => {
const appKey = route.params.appKey as string
loadData()
updateViewport()
window.addEventListener('resize', updateViewport)
void connectServiceActivationRealtime(appKey, (event) => {
if (event.status === 'APPROVED') {
ElMessage.success(`${serviceLabel(event.serviceType)} 已审核通过`)
loadData()
} else if (event.status === 'REJECTED') {
ElMessage.error(`${serviceLabel(event.serviceType)} 审核未通过:${event.reviewNote || '请联系运营'}`)
loadData()
}
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
disconnectServiceActivationRealtime()
})
</script>

查看文件

@ -126,6 +126,11 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { licenseApi, type AppLicense, type LicenseDevice } from '@/api/license'
import { appApi, type App } from '@/api/app'
import {
connectServiceActivationRealtime,
disconnectServiceActivationRealtime,
type ServiceActivationRefreshEvent,
} from '@/services/storeReviewRealtime'
const route = useRoute()
const router = useRouter()
@ -261,12 +266,26 @@ function updateViewport() {
isMobile.value = window.innerWidth < 768
}
function connectLicenseRealtime(key: string) {
void connectServiceActivationRealtime(key, (event: ServiceActivationRefreshEvent) => {
if (event.serviceType !== 'LICENSE') return
if (event.status === 'APPROVED') {
ElMessage.success('授权管理服务已审核通过')
checkServiceEnabled(key)
} else if (event.status === 'REJECTED') {
ElMessage.error(`授权管理服务审核未通过:${event.reviewNote || '请联系运营'}`)
checkServiceEnabled(key)
}
})
}
watch(appKey, (key) => {
if (isServicesPortal.value) {
if (key) checkServiceEnabled(key)
} else {
loadData()
}
if (key) connectLicenseRealtime(key)
})
onMounted(() => {
@ -278,13 +297,15 @@ onMounted(() => {
currentApp.value = portalApps.value.find(item => item.appKey === appKey.value) ?? currentApp.value
})
if (appKey.value) checkServiceEnabled(appKey.value)
return
} else {
loadData()
}
loadData()
if (appKey.value) connectLicenseRealtime(appKey.value)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
disconnectServiceActivationRealtime()
})
</script>

查看文件

@ -173,6 +173,11 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { appApi, type App } from '@/api/app'
import { pushAdminApi, type DeviceLoginLog, type TestPushResult, type UserPushStatus } from '@/api/push'
import {
connectServiceActivationRealtime,
disconnectServiceActivationRealtime,
type ServiceActivationRefreshEvent,
} from '@/services/storeReviewRealtime'
const route = useRoute()
const router = useRouter()
@ -308,8 +313,22 @@ function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('zh-CN')
}
function connectPushRealtime(key: string) {
void connectServiceActivationRealtime(key, (event: ServiceActivationRefreshEvent) => {
if (event.serviceType !== 'PUSH') return
if (event.status === 'APPROVED') {
ElMessage.success('离线推送服务已审核通过')
checkServiceEnabled()
} else if (event.status === 'REJECTED') {
ElMessage.error(`离线推送服务审核未通过:${event.reviewNote || '请联系运营'}`)
checkServiceEnabled()
}
})
}
watch(appKey, (key) => {
if (isServicesPortal.value && key) checkServiceEnabled()
if (key) connectPushRealtime(key)
})
onMounted(() => {
@ -319,9 +338,13 @@ onMounted(() => {
checkServiceEnabled()
}
}
if (appKey.value) connectPushRealtime(appKey.value)
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
disconnectServiceActivationRealtime()
})
</script>
<style scoped>

查看文件

@ -581,10 +581,20 @@
</div>
<!-- Rejection / withdrawal reason -->
<div v-if="item.reason && (item.state === 'REJECTED' || item.state === 'WITHDRAWN')" class="review-card-reason">
<div v-if="item.reason && (item.state === 'REJECTED' || item.state === 'WITHDRAWN' || item.state === 'FAILED')" class="review-card-reason">
<el-icon><WarningFilled /></el-icon>
<span>{{ item.reason }}</span>
</div>
<!-- Retry button for failed or rejected submissions -->
<div v-if="item.state === 'FAILED' || item.state === 'REJECTED'" class="review-card-retry">
<el-button
type="warning"
size="small"
:loading="retryingStores.has(item.store)"
@click="handleRetryStore(item.store)"
>重新提交</el-button>
</div>
</div>
</div>
@ -1397,6 +1407,7 @@ const storeReviewDetailVersion = ref<AppVersion | null>(null)
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string }[]>([])
const storeReviewDetailLive = ref(false)
const cancellingReview = ref(false)
const retryingStores = ref<Set<string>>(new Set())
const showPublishSchedule = ref(false)
const publishScheduleType = ref<'IMMEDIATE' | 'SCHEDULED'>('IMMEDIATE')
const publishScheduleAt = ref('')
@ -1425,6 +1436,26 @@ async function handleCancelReview(storeType?: string) {
}
}
async function handleRetryStore(storeType: string) {
if (!storeReviewDetailVersion.value) return
retryingStores.value = new Set([...retryingStores.value, storeType])
try {
await updateAdminApi.executeSubmitToStores(
storeReviewDetailVersion.value.id,
[storeType] as any,
storeReviewDetailVersion.value.storeSubmitMode ?? 'MANUAL',
)
ElMessage.success(`${storeLabel(storeType)} 重试已提交`)
const idx = storeReviewDetailItems.value.findIndex(i => i.store === storeType)
if (idx >= 0) storeReviewDetailItems.value[idx] = { ...storeReviewDetailItems.value[idx], state: 'PENDING', stage: 'QUEUED' }
await loadAppVersions()
} catch {
ElMessage.error(`${storeLabel(storeType)} 重试失败,请稍后再试`)
} finally {
retryingStores.value = new Set([...retryingStores.value].filter(s => s !== storeType))
}
}
async function handleUpdatePublishSchedule() {
if (!storeReviewDetailVersion.value) return
updatingPublishSchedule.value = true
@ -1996,14 +2027,14 @@ function storeLabel(type: string) {
function reviewLabel(state: string): string {
return {
PENDING: '待提交', SUBMITTING: '提交中', UNDER_REVIEW: '审核中',
APPROVED: '已通过', REJECTED: '已拒绝', WITHDRAWN: '已撤回',
APPROVED: '已通过', REJECTED: '已拒绝', WITHDRAWN: '已撤回', FAILED: '提交失败',
}[state] ?? state
}
function reviewTagType(state: string): string {
return {
PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning',
APPROVED: 'success', REJECTED: 'danger', WITHDRAWN: '',
APPROVED: 'success', REJECTED: 'danger', WITHDRAWN: '', FAILED: 'danger',
}[state] ?? ''
}
@ -2637,6 +2668,10 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
.review-store-card.review-card--failed {
border-color: var(--el-color-danger-light-5);
}
.review-card-head {
display: flex;
align-items: center;
@ -2786,4 +2821,9 @@ onBeforeUnmount(() => {
margin-top: 6px;
line-height: 1.5;
}
.review-card-retry {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
</style>

查看文件

@ -1429,10 +1429,10 @@
dependencies:
vue "^3.5.13"
"@xuqm/vue3-sdk@^0.2.2":
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==
"@xuqm/vue3-sdk@0.2.3", "@xuqm/vue3-sdk@^0.2.3":
version "0.2.3"
resolved "https://nexus.xuqinmin.com/repository/npm-hosted/@xuqm/vue3-sdk/-/vue3-sdk-0.2.3.tgz#b0100a309bf4179a43d98e21e24759b020221797"
integrity sha512-lUQSQNaYyrji8WwL/N4Xswdzyo4MrbwqSHsghC2Sg/zEGtuTAxo+D7t8pK1wTRLXwR5P2ZutYhE75OIFhXMFLg==
abbrev@^2.0.0:
version "2.0.0"