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>
这个提交包含在:
父节点
ce05fa4fe3
当前提交
6b891bee92
@ -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,25 +20,39 @@ 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
|
||||
if (!appKey) return null
|
||||
|
||||
if (event === 'store_review_update') {
|
||||
return {
|
||||
event,
|
||||
appKey,
|
||||
@ -48,14 +66,36 @@ function parseEvent(message: ImMessage): StoreReviewRefreshEvent | null {
|
||||
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()
|
||||
}
|
||||
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"
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户