feat(push): 添加多厂商推送集成支持
- 实现了华为 HMS 推送服务集成 - 实现了小米推送服务集成 - 实现了 OPPO 推送服务集成 - 实现了 vivo 推送服务集成 - 实现了荣耀推送服务集成 - 实现了 FCM 推送服务集成 - 添加了统一的厂商推送接口和检测机制 - 添加了推送配置 API 和存储管理 - 添加了推送令牌管理和设备注册功能 - 添加了模拟器环境的推送测试用例
这个提交包含在:
父节点
e1925414c9
当前提交
dcbd833e64
1
ops-platform/components.d.ts
vendored
1
ops-platform/components.d.ts
vendored
@ -16,6 +16,7 @@ declare module 'vue' {
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
|
||||
@ -131,6 +131,64 @@ export interface SensitiveWordPage {
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface PushDeviceInfo {
|
||||
id: string
|
||||
vendor: string
|
||||
tokenPreview: string
|
||||
platform?: string
|
||||
deviceId: string
|
||||
brand?: string
|
||||
model?: string
|
||||
osVersion?: string
|
||||
appVersion?: string
|
||||
receivePush: boolean
|
||||
lastLoginAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PushDiagnostics {
|
||||
tokenType: 'PUSH' | 'IM' | 'UNKNOWN'
|
||||
appId?: string
|
||||
userId?: string
|
||||
online: boolean
|
||||
lastSeenAt: number
|
||||
canSendOfflineMessage: boolean
|
||||
deliverableDevice?: PushDeviceInfo | null
|
||||
deliverableDevices: PushDeviceInfo[]
|
||||
devices: PushDeviceInfo[]
|
||||
}
|
||||
|
||||
export interface PushDeviceLog {
|
||||
id: string
|
||||
appId: string
|
||||
userId: string
|
||||
vendor: string
|
||||
tokenPreview: string
|
||||
platform?: string
|
||||
deviceId: string
|
||||
brand?: string
|
||||
model?: string
|
||||
osVersion?: string
|
||||
appVersion?: string
|
||||
receivePush: boolean
|
||||
eventType: 'REGISTER' | 'UNREGISTER' | 'RECEIVE_PUSH_UPDATE'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PushDeviceLogPage {
|
||||
content: PushDeviceLog[]
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface PushTestResult {
|
||||
appId: string
|
||||
userId: string
|
||||
sent: boolean
|
||||
targetCount: number
|
||||
targets: PushDeviceInfo[]
|
||||
}
|
||||
|
||||
export const opsApi = {
|
||||
listTenants: (keyword = '', page = 0, size = 20) =>
|
||||
client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }),
|
||||
@ -189,4 +247,13 @@ export const opsApi = {
|
||||
|
||||
deleteSensitiveWord: (id: string) =>
|
||||
client.delete(`/ops/risk/sensitive-words/${id}`),
|
||||
|
||||
searchPushByToken: (token: string, appId = '') =>
|
||||
client.get<{ data: PushDiagnostics }>('/ops/push/search', { params: { token, appId } }),
|
||||
|
||||
listPushDeviceLogs: (appId: string, userId: string, page = 0, size = 20) =>
|
||||
client.get<{ data: PushDeviceLogPage }>('/ops/push/device-logs', { params: { appId, userId, page, size } }),
|
||||
|
||||
sendPushTestOffline: (payload: { appId: string; userId: string; title: string; body: string; payload?: string }) =>
|
||||
client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload),
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ const router = createRouter({
|
||||
{ path: 'service-requests', component: () => import('@/views/services/ServiceRequestsView.vue') },
|
||||
{ path: 'apps', component: () => import('@/views/apps/AppListView.vue') },
|
||||
{ path: 'apps/:id', component: () => import('@/views/apps/AppDetailView.vue') },
|
||||
{ path: 'push', component: () => import('@/views/push/PushDiagnosticsView.vue') },
|
||||
{ path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') },
|
||||
{ path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') },
|
||||
],
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const isMobile = ref(false)
|
||||
@ -77,6 +77,7 @@ const navItems = computed(() => [
|
||||
{ path: '/statistics', label: '服务运营总览', icon: TrendCharts },
|
||||
{ path: '/service-requests', label: '服务开通审核', icon: Bell },
|
||||
{ path: '/apps', label: '应用管理', icon: Grid },
|
||||
{ path: '/push', label: 'Push 诊断', icon: Promotion },
|
||||
{ path: '/operation-logs', label: '操作日志', icon: Document },
|
||||
{ path: '/risk-control', label: '风控配置', icon: Warning },
|
||||
])
|
||||
|
||||
@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="push-page">
|
||||
<el-card class="query-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>Push 诊断</span>
|
||||
<div class="header-actions">
|
||||
<el-tag v-if="diagnostics" :type="diagnostics.online ? 'success' : 'info'">
|
||||
{{ diagnostics.online ? '用户在线' : '用户离线' }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="diagnostics?.appId && diagnostics?.userId"
|
||||
type="primary"
|
||||
:disabled="!diagnostics.deliverableDevices?.length"
|
||||
:loading="testLoading"
|
||||
@click="sendTestOffline"
|
||||
>
|
||||
发送测试离线消息
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="96px" class="query-form" @submit.prevent="search">
|
||||
<el-form-item label="Token">
|
||||
<el-input
|
||||
v-model="form.token"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入 IM token 或厂商 push token"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="AppId">
|
||||
<el-input v-model="form.appId" placeholder="可选;push token 无法解析 AppId 时使用" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<template v-if="diagnostics">
|
||||
<el-row :gutter="16" class="summary-row">
|
||||
<el-col :xs="24" :md="8">
|
||||
<el-card>
|
||||
<el-statistic title="Token 类型" :value="diagnostics.tokenType" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="8">
|
||||
<el-card>
|
||||
<el-statistic title="用户" :value="diagnostics.userId || '-'" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="8">
|
||||
<el-card>
|
||||
<el-statistic title="离线消息" :value="diagnostics.canSendOfflineMessage ? '可送达' : '不可送达'" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>当前离线可送达设备</template>
|
||||
<el-empty v-if="!diagnostics.deliverableDevices?.length" description="暂无可送达设备" />
|
||||
<el-table v-else :data="diagnostics.deliverableDevices" border stripe>
|
||||
<el-table-column prop="vendor" label="厂商" width="100" />
|
||||
<el-table-column label="设备" min-width="180">
|
||||
<template #default="{ row }">{{ deviceName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="osVersion" label="系统" width="140" />
|
||||
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
|
||||
<el-table-column label="最近登录" width="180">
|
||||
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>当前绑定设备</template>
|
||||
<el-table :data="diagnostics.devices" border stripe>
|
||||
<el-table-column prop="vendor" label="厂商" width="100" />
|
||||
<el-table-column label="设备" min-width="180">
|
||||
<template #default="{ row }">{{ deviceName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="osVersion" label="系统" width="140" />
|
||||
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
|
||||
<el-table-column label="接收 Push" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.receivePush ? 'success' : 'info'">{{ row.receivePush ? '开启' : '关闭' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" width="180">
|
||||
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>历史登录设备日志</span>
|
||||
<el-button :disabled="!diagnostics.appId || !diagnostics.userId" :loading="logsLoading" @click="loadLogs">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="logs" border stripe>
|
||||
<el-table-column prop="eventType" label="事件" width="150" />
|
||||
<el-table-column prop="vendor" label="厂商" width="100" />
|
||||
<el-table-column label="设备" min-width="180">
|
||||
<template #default="{ row }">{{ deviceName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceId" label="DeviceId" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="tokenPreview" label="Token" min-width="170" />
|
||||
<el-table-column label="接收 Push" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.receivePush ? 'success' : 'info'">{{ row.receivePush ? '开启' : '关闭' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pager"
|
||||
layout="prev, pager, next, total"
|
||||
:total="logTotal"
|
||||
:page-size="logSize"
|
||||
:current-page="logPage + 1"
|
||||
@current-change="changeLogPage"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { opsApi, type PushDeviceInfo, type PushDeviceLog, type PushDiagnostics } from '@/api/ops'
|
||||
|
||||
const form = reactive({ token: '', appId: '' })
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const testLoading = ref(false)
|
||||
const diagnostics = ref<PushDiagnostics | null>(null)
|
||||
const logs = ref<PushDeviceLog[]>([])
|
||||
const logTotal = ref(0)
|
||||
const logPage = ref(0)
|
||||
const logSize = 20
|
||||
|
||||
async function search() {
|
||||
if (!form.token.trim()) {
|
||||
ElMessage.warning('请输入 token')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await opsApi.searchPushByToken(form.token.trim(), form.appId.trim())
|
||||
diagnostics.value = res.data.data
|
||||
logs.value = []
|
||||
logTotal.value = 0
|
||||
logPage.value = 0
|
||||
if (diagnostics.value.appId && diagnostics.value.userId) {
|
||||
await loadLogs()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!diagnostics.value?.appId || !diagnostics.value?.userId) return
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await opsApi.listPushDeviceLogs(
|
||||
diagnostics.value.appId,
|
||||
diagnostics.value.userId,
|
||||
logPage.value,
|
||||
logSize,
|
||||
)
|
||||
logs.value = res.data.data.content
|
||||
logTotal.value = res.data.data.total
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeLogPage(page: number) {
|
||||
logPage.value = page - 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
async function sendTestOffline() {
|
||||
if (!diagnostics.value?.appId || !diagnostics.value?.userId) return
|
||||
testLoading.value = true
|
||||
try {
|
||||
const res = await opsApi.sendPushTestOffline({
|
||||
appId: diagnostics.value.appId,
|
||||
userId: diagnostics.value.userId,
|
||||
title: 'XuqmGroup Push 测试',
|
||||
body: `离线推送测试 ${new Date().toLocaleString('zh-CN')}`,
|
||||
payload: JSON.stringify({ type: 'PUSH_TEST', ts: Date.now() }),
|
||||
})
|
||||
ElMessage.success(`已发送到 ${res.data.data.targetCount} 个设备`)
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deviceName(device: PushDeviceInfo | PushDeviceLog) {
|
||||
return [device.brand, device.model].filter(Boolean).join(' ') || '-'
|
||||
}
|
||||
|
||||
function fmt(value?: string | number) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.push-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.query-card,
|
||||
.section-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.query-form {
|
||||
max-width: 920px;
|
||||
}
|
||||
.summary-row {
|
||||
row-gap: 16px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@ -116,7 +116,7 @@ export const appApi = {
|
||||
appId: string,
|
||||
platform: string,
|
||||
serviceType: string,
|
||||
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig>,
|
||||
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig> | Record<string, unknown>,
|
||||
) =>
|
||||
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
|
||||
params: { platform, serviceType },
|
||||
|
||||
@ -50,8 +50,7 @@
|
||||
<el-input
|
||||
v-else
|
||||
v-model="pushConfig[vendor.key][field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:show-password="field.type === 'password'"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
@ -78,7 +77,7 @@ type VendorKey = keyof PushServiceConfig
|
||||
type FieldDef = {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'password' | 'textarea' | 'switch'
|
||||
type?: 'textarea' | 'switch'
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}
|
||||
@ -117,7 +116,7 @@ const vendorDefs: VendorDef[] = [
|
||||
hint: '填写 AppId / AppSecret,供服务端推送使用。',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -127,7 +126,7 @@ const vendorDefs: VendorDef[] = [
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -137,7 +136,7 @@ const vendorDefs: VendorDef[] = [
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'masterSecret', label: 'MasterSecret', type: 'password' },
|
||||
{ key: 'masterSecret', label: 'MasterSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -147,7 +146,7 @@ const vendorDefs: VendorDef[] = [
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'appKey', label: 'AppKey' },
|
||||
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
|
||||
{ key: 'appSecret', label: 'AppSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -157,7 +156,7 @@ const vendorDefs: VendorDef[] = [
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppId' },
|
||||
{ key: 'clientId', label: 'ClientId' },
|
||||
{ key: 'clientSecret', label: 'ClientSecret', type: 'password' },
|
||||
{ key: 'clientSecret', label: 'ClientSecret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -212,7 +211,7 @@ async function saveConfig() {
|
||||
if (!app.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', pushConfig)
|
||||
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', toPushConfigRequest())
|
||||
ElMessage.success('推送配置已保存')
|
||||
await loadData()
|
||||
} catch {
|
||||
@ -222,6 +221,31 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function toPushConfigRequest(): Record<string, string | boolean> {
|
||||
return {
|
||||
huaweiAppId: pushConfig.huawei.appId ?? '',
|
||||
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
|
||||
xiaomiAppId: pushConfig.xiaomi.appId ?? '',
|
||||
xiaomiAppKey: pushConfig.xiaomi.appKey ?? '',
|
||||
xiaomiAppSecret: pushConfig.xiaomi.appSecret ?? '',
|
||||
oppoAppId: pushConfig.oppo.appId ?? '',
|
||||
oppoAppKey: pushConfig.oppo.appKey ?? '',
|
||||
oppoMasterSecret: pushConfig.oppo.masterSecret ?? '',
|
||||
vivoAppId: pushConfig.vivo.appId ?? '',
|
||||
vivoAppKey: pushConfig.vivo.appKey ?? '',
|
||||
vivoAppSecret: pushConfig.vivo.appSecret ?? '',
|
||||
honorAppId: pushConfig.honor.appId ?? '',
|
||||
honorClientId: pushConfig.honor.clientId ?? '',
|
||||
honorClientSecret: pushConfig.honor.clientSecret ?? '',
|
||||
apnsTeamId: pushConfig.apns.teamId ?? '',
|
||||
apnsKeyId: pushConfig.apns.keyId ?? '',
|
||||
apnsBundleId: pushConfig.apns.bundleId ?? '',
|
||||
apnsKeyPath: pushConfig.apns.keyPath ?? '',
|
||||
apnsSandbox: pushConfig.apns.sandbox ?? false,
|
||||
fcmServiceAccountJson: pushConfig.fcm.serviceAccountJson ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
async function onTogglePushService(enable: boolean) {
|
||||
if (enable) {
|
||||
ElMessage.info('请通过服务开通流程启用推送服务')
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户