feat(push): 添加推送管理页面
在租户平台「离线推送」卡片中新增「推送管理 →」入口,新增 /apps/:appId/push-management 路由及 PushManagementView 页面。 页面支持按用户 ID 查询设备在线状态与注册设备列表、发送测试 离线推送消息、浏览设备登录日志。同步新增 push.ts API 客户端 及 vite 开发代理对 /api/push 的路由。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
0f57fe3b71
当前提交
7b9e955c7e
80
tenant-platform/src/api/push.ts
普通文件
80
tenant-platform/src/api/push.ts
普通文件
@ -0,0 +1,80 @@
|
|||||||
|
import client from '@/api/client'
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string
|
||||||
|
vendor: string
|
||||||
|
tokenPreview: string
|
||||||
|
platform: string | null
|
||||||
|
deviceId: string | null
|
||||||
|
brand: string | null
|
||||||
|
model: string | null
|
||||||
|
osVersion: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
receivePush: boolean
|
||||||
|
lastLoginAt: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPushStatus {
|
||||||
|
tokenType: string
|
||||||
|
appId: string
|
||||||
|
userId: string
|
||||||
|
online: boolean
|
||||||
|
lastSeenAt: number
|
||||||
|
canSendOfflineMessage: boolean
|
||||||
|
deliverableDevice: DeviceInfo | null
|
||||||
|
deliverableDevices: DeviceInfo[]
|
||||||
|
devices: DeviceInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLoginLog {
|
||||||
|
id: string
|
||||||
|
appId: string
|
||||||
|
userId: string
|
||||||
|
vendor: string
|
||||||
|
deviceId: string | null
|
||||||
|
brand: string | null
|
||||||
|
model: string | null
|
||||||
|
platform: string | null
|
||||||
|
osVersion: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedLogs {
|
||||||
|
content: DeviceLoginLog[]
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestPushResult {
|
||||||
|
appId: string
|
||||||
|
userId: string
|
||||||
|
sent: boolean
|
||||||
|
targetCount: number
|
||||||
|
targets: DeviceInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushAdminApi = {
|
||||||
|
getUserStatus(appId: string, userId: string) {
|
||||||
|
return client.get<{ data: UserPushStatus }>('/push/admin/user-status', {
|
||||||
|
params: { appId, userId },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getDeviceLogs(appId: string, userId: string, page = 0, size = 20) {
|
||||||
|
return client.get<{ data: PagedLogs }>('/push/admin/device-logs', {
|
||||||
|
params: { appId, userId, page, size },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
testOffline(appId: string, userId: string, title: string, body: string, payload?: string) {
|
||||||
|
return client.post<{ data: TestPushResult }>('/push/admin/test-offline', {
|
||||||
|
appId,
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
payload: payload ?? null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -57,6 +57,10 @@ const router = createRouter({
|
|||||||
path: 'apps/:appId/push-config',
|
path: 'apps/:appId/push-config',
|
||||||
component: () => import('@/views/push/PushConfigView.vue'),
|
component: () => import('@/views/push/PushConfigView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'apps/:appId/push-management',
|
||||||
|
component: () => import('@/views/push/PushManagementView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'apps/:appId/im-webhooks',
|
path: 'apps/:appId/im-webhooks',
|
||||||
component: () => import('@/views/im/ImWebhookView.vue'),
|
component: () => import('@/views/im/ImWebhookView.vue'),
|
||||||
|
|||||||
@ -77,9 +77,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-actions">
|
<div class="service-actions">
|
||||||
<el-button v-if="isServiceEnabled('PUSH')" size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/push-config`)">
|
<template v-if="isServiceEnabled('PUSH')">
|
||||||
推送配置 →
|
<el-button size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/push-config`)">
|
||||||
</el-button>
|
推送配置 →
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="$router.push(`/apps/${route.params.id}/push-management`)">
|
||||||
|
推送管理 →
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('PUSH')">
|
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('PUSH')">
|
||||||
申请开通
|
申请开通
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|||||||
@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-page-header @back="$router.back()" content="推送管理" style="margin-bottom:24px" />
|
||||||
|
|
||||||
|
<el-card style="margin-bottom:16px">
|
||||||
|
<template #header>用户设备状态查询</template>
|
||||||
|
<el-form inline @submit.prevent="queryUser">
|
||||||
|
<el-form-item label="用户 ID">
|
||||||
|
<el-input v-model="queryUserId" placeholder="请输入用户 ID" clearable style="width:240px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="querying" @click="queryUser">查询</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template v-if="userStatus">
|
||||||
|
<el-divider />
|
||||||
|
<el-descriptions :column="isMobile ? 1 : 3" border size="small" style="margin-bottom:16px">
|
||||||
|
<el-descriptions-item label="用户 ID">{{ userStatus.userId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="在线状态">
|
||||||
|
<el-tag :type="userStatus.online ? 'success' : 'info'">
|
||||||
|
{{ userStatus.online ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最后在线">
|
||||||
|
{{ userStatus.lastSeenAt ? formatTime(userStatus.lastSeenAt) : '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="可推送设备数">
|
||||||
|
{{ userStatus.deliverableDevices.length }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="离线可达">
|
||||||
|
<el-tag :type="userStatus.canSendOfflineMessage ? 'success' : 'warning'">
|
||||||
|
{{ userStatus.canSendOfflineMessage ? '可发送' : '不可发送' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="注册设备总数">{{ userStatus.devices.length }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-table :data="userStatus.devices" border size="small" style="margin-bottom:16px">
|
||||||
|
<el-table-column label="厂商" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain">{{ row.vendor }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平台" prop="platform" width="90" />
|
||||||
|
<el-table-column label="品牌" prop="brand" width="80" />
|
||||||
|
<el-table-column label="型号" prop="model" min-width="110" />
|
||||||
|
<el-table-column label="系统版本" prop="osVersion" width="100" />
|
||||||
|
<el-table-column label="App 版本" prop="appVersion" width="90" />
|
||||||
|
<el-table-column label="Token 预览" prop="tokenPreview" min-width="140" />
|
||||||
|
<el-table-column label="接收推送" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.receivePush ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.receivePush ? '是' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后登录" min-width="160">
|
||||||
|
<template #default="{ row }">{{ row.lastLoginAt ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-card shadow="never" style="margin-bottom:0">
|
||||||
|
<template #header>发送测试离线推送</template>
|
||||||
|
<el-form :model="testForm" label-width="90px" style="max-width:560px">
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="testForm.title" placeholder="推送标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容">
|
||||||
|
<el-input v-model="testForm.body" placeholder="推送内容" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Payload">
|
||||||
|
<el-input v-model="testForm.payload" placeholder="自定义 JSON payload(可选)" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!testForm.title || !testForm.body"
|
||||||
|
:loading="sending"
|
||||||
|
@click="sendTestPush"
|
||||||
|
>发送</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template v-if="testResult">
|
||||||
|
<el-alert
|
||||||
|
:title="testResult.sent ? `推送已发出,目标设备 ${testResult.targetCount} 台` : '无可用推送目标,未发送'"
|
||||||
|
:type="testResult.sent ? 'success' : 'warning'"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-top:12px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>设备登录日志</span>
|
||||||
|
<span v-if="logsUserId" style="font-size:12px;color:#909399;margin-left:8px">
|
||||||
|
用户:{{ logsUserId }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<el-form inline style="margin-bottom:12px" @submit.prevent="loadLogs">
|
||||||
|
<el-form-item label="用户 ID">
|
||||||
|
<el-input v-model="logsUserId" placeholder="请输入用户 ID" clearable style="width:240px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="logsLoading" @click="loadLogs">查询日志</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="logs" border size="small" v-loading="logsLoading">
|
||||||
|
<el-table-column label="厂商" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain">{{ row.vendor }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平台" prop="platform" width="90" />
|
||||||
|
<el-table-column label="品牌" prop="brand" width="80" />
|
||||||
|
<el-table-column label="型号" prop="model" min-width="110" />
|
||||||
|
<el-table-column label="系统版本" prop="osVersion" width="100" />
|
||||||
|
<el-table-column label="App 版本" prop="appVersion" width="90" />
|
||||||
|
<el-table-column label="设备 ID" prop="deviceId" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="登录时间" prop="createdAt" min-width="160" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="logsTotalPages > 1" style="margin-top:12px;display:flex;justify-content:flex-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="logsPage"
|
||||||
|
:page-size="logsPageSize"
|
||||||
|
:total="logsTotal"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
@current-change="loadLogs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { pushAdminApi, type DeviceLoginLog, type TestPushResult, type UserPushStatus } from '@/api/push'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appId = route.params.appId as string
|
||||||
|
|
||||||
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
|
function updateViewport() { isMobile.value = window.innerWidth < 768 }
|
||||||
|
|
||||||
|
const queryUserId = ref('')
|
||||||
|
const querying = ref(false)
|
||||||
|
const userStatus = ref<UserPushStatus | null>(null)
|
||||||
|
|
||||||
|
const testForm = reactive({ title: '', body: '', payload: '' })
|
||||||
|
const sending = ref(false)
|
||||||
|
const testResult = ref<TestPushResult | null>(null)
|
||||||
|
|
||||||
|
const logsUserId = ref('')
|
||||||
|
const logsLoading = ref(false)
|
||||||
|
const logs = ref<DeviceLoginLog[]>([])
|
||||||
|
const logsPage = ref(1)
|
||||||
|
const logsPageSize = 20
|
||||||
|
const logsTotal = ref(0)
|
||||||
|
const logsTotalPages = ref(0)
|
||||||
|
|
||||||
|
async function queryUser() {
|
||||||
|
const uid = queryUserId.value.trim()
|
||||||
|
if (!uid) {
|
||||||
|
ElMessage.warning('请输入用户 ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
querying.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await pushAdminApi.getUserStatus(appId, uid)
|
||||||
|
userStatus.value = res.data.data
|
||||||
|
logsUserId.value = uid
|
||||||
|
logsPage.value = 1
|
||||||
|
await loadLogs()
|
||||||
|
} catch {
|
||||||
|
userStatus.value = null
|
||||||
|
} finally {
|
||||||
|
querying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestPush() {
|
||||||
|
if (!userStatus.value) return
|
||||||
|
sending.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await pushAdminApi.testOffline(
|
||||||
|
appId,
|
||||||
|
userStatus.value.userId,
|
||||||
|
testForm.title,
|
||||||
|
testForm.body,
|
||||||
|
testForm.payload || undefined,
|
||||||
|
)
|
||||||
|
testResult.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
// error shown by client interceptor
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const uid = logsUserId.value.trim()
|
||||||
|
if (!uid) return
|
||||||
|
logsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await pushAdminApi.getDeviceLogs(appId, uid, logsPage.value - 1, logsPageSize)
|
||||||
|
const d = res.data.data
|
||||||
|
logs.value = d.content
|
||||||
|
logsTotal.value = d.total
|
||||||
|
logsTotalPages.value = d.totalPages
|
||||||
|
} catch {
|
||||||
|
logs.value = []
|
||||||
|
} finally {
|
||||||
|
logsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
if (!ms) return '-'
|
||||||
|
return new Date(ms).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('resize', updateViewport))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
|
||||||
|
</script>
|
||||||
@ -42,6 +42,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/api/push': {
|
||||||
|
target: 'http://127.0.0.1:8083',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8081',
|
target: 'http://127.0.0.1:8081',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户