feat: add quick service entry portal with in-page app switcher

- Add sidebar sub-menu for 服务管理 (IM / 离线推送 / 版本管理)
- Service pages load directly with optional appId route param
- Each service page shows a portal bar (app selector) when accessed via /services/* path
- Content is guarded with v-if so empty state shows when no app is selected
- Router-view keyed by path so component re-creates on app switch
- App-level package name split into Android/iOS/HarmonyOS fields
- Push vendor channel config: Xiaomi channelId, Huawei category, vivo category+receiptId, OPPO channelId
- Remove packageName from push vendor config (moved to app-level)
- Format device last-login time in push management view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-07 13:53:02 +08:00
父节点 f24b467308
当前提交 f36d657bba
共有 10 个文件被更改,包括 197 次插入49 次删除

查看文件

@ -43,6 +43,7 @@ declare module 'vue' {
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
@ -53,6 +54,7 @@ declare module 'vue' {
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']

查看文件

@ -4,6 +4,8 @@ export interface App {
id: string
tenantId: string
packageName: string
iosBundleId?: string
harmonyBundleName?: string
name: string
description?: string
iconUrl?: string
@ -14,6 +16,8 @@ export interface App {
export interface CreateAppRequest {
packageName: string
iosBundleId?: string
harmonyBundleName?: string
name: string
description?: string
iconUrl?: string
@ -61,7 +65,6 @@ export interface PushVendorConfig {
appId?: string
appKey?: string
appSecret?: string
packageName?: string
masterSecret?: string
clientId?: string
clientSecret?: string
@ -71,6 +74,9 @@ export interface PushVendorConfig {
keyPath?: string
sandbox?: boolean
serviceAccountJson?: string
channelId?: string
category?: string
receiptId?: string
}
export interface PushServiceConfig {

查看文件

@ -81,6 +81,18 @@ const router = createRouter({
path: 'apps/:appId/update',
component: () => import('@/views/update/VersionManagementView.vue'),
},
{
path: 'services/im/:appId?',
component: () => import('@/views/im/ImManagementView.vue'),
},
{
path: 'services/push/:appId?',
component: () => import('@/views/push/PushManagementView.vue'),
},
{
path: 'services/update/:appId?',
component: () => import('@/views/update/VersionManagementView.vue'),
},
{
path: 'accounts',
component: () => import('@/views/accounts/SubAccountView.vue'),

查看文件

@ -5,7 +5,9 @@
<el-card class="info-card" style="margin-bottom:16px">
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="Android 包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="iOS Bundle ID">{{ app.iosBundleId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="鸿蒙 Bundle Name">{{ app.harmonyBundleName ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="AppKey">
<el-text class="mono">{{ app.appKey }}</el-text>
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>

查看文件

@ -26,9 +26,15 @@
<el-dialog v-model="showCreate" title="创建应用" :width="dialogWidth">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top">
<el-form-item label="包名" prop="packageName">
<el-form-item label="Android 包名" prop="packageName">
<el-input v-model="createForm.packageName" placeholder="com.example.app" />
</el-form-item>
<el-form-item label="iOS Bundle ID">
<el-input v-model="createForm.iosBundleId" placeholder="com.example.app" />
</el-form-item>
<el-form-item label="鸿蒙 Bundle Name">
<el-input v-model="createForm.harmonyBundleName" placeholder="com.example.app" />
</el-form-item>
<el-form-item label="应用名称" prop="name">
<el-input v-model="createForm.name" />
</el-form-item>
@ -58,7 +64,7 @@ const createFormRef = ref<FormInstance>()
const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '480px'))
const createForm = reactive({ packageName: '', name: '', description: '' })
const createForm = reactive({ packageName: '', iosBundleId: '', harmonyBundleName: '', name: '', description: '' })
const createRules: FormRules = {
packageName: [{ required: true, message: '请输入包名' }],
name: [{ required: true, message: '请输入应用名' }],

查看文件

@ -1,7 +1,15 @@
<template>
<div>
<el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appKey}`" style="margin-bottom:20px" />
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">即时通讯管理</span>
<el-select :model-value="appKey" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.appKey" :label="a.name" :value="a.appKey" />
</el-select>
</div>
<el-page-header v-else @back="$router.back()" :content="`即时通讯管理 — ${appKey}`" style="margin-bottom:20px" />
<el-empty v-if="isServicesPortal && !appKey" description="请选择一个应用" style="margin-top:80px" />
<template v-if="!isServicesPortal || appKey">
<el-row :gutter="16" class="stat-grid">
<el-col :xs="24" :sm="12" :md="6" v-for="item in statCards" :key="item.label">
<el-card shadow="never">
@ -738,12 +746,14 @@
</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { appApi, type App } from '@/api/app'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
@ -760,6 +770,9 @@ import {
} from '@/api/im'
const route = useRoute()
const router = useRouter()
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
const portalApps = ref<App[]>([])
// IM appKey tenant-service
const appKey = computed(() => {
const queryAppKey = route.query.appKey
@ -1137,6 +1150,10 @@ function resetMessageSearch() {
historyPage.value = 0
}
function switchApp(val: string) {
router.push(`/services/im/${val}`)
}
async function loadStats() {
try {
const res = await imAdminApi.getStats(appKey.value)
@ -1779,6 +1796,10 @@ function handleOperationLogPageChange(page: number) {
}
onMounted(() => {
if (isServicesPortal.value) {
appApi.list().then(res => { portalApps.value = res.data.data })
if (!appKey.value) return
}
loadStats()
loadUsers()
})
@ -2030,4 +2051,15 @@ function userAvatarFallback(row: ImUser) {
max-height: 240px;
}
}
.portal-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.portal-bar-title {
font-size: 16px;
font-weight: 600;
}
</style>

查看文件

@ -6,16 +6,25 @@
</div>
<el-menu
:default-active="$route.path"
:default-openeds="openedMenus"
router
background-color="#1d2129"
text-color="#c9d1d9"
active-text-color="#409eff"
class="nav-menu"
>
<el-menu-item v-for="item in navItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon><span></span></el-menu-item>
<el-menu-item index="/apps"><el-icon><Grid /></el-icon><span></span></el-menu-item>
<el-sub-menu index="services">
<template #title><el-icon><List /></el-icon><span></span></template>
<el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item>
<el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item>
</el-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item>
<el-menu-item index="/docs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu>
</el-aside>
@ -31,6 +40,7 @@
</div>
<el-menu
:default-active="$route.path"
:default-openeds="openedMenus"
router
background-color="#1d2129"
text-color="#c9d1d9"
@ -38,10 +48,18 @@
class="nav-menu"
@select="handleNavSelect"
>
<el-menu-item v-for="item in navItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon><span></span></el-menu-item>
<el-menu-item index="/apps"><el-icon><Grid /></el-icon><span></span></el-menu-item>
<el-sub-menu index="services">
<template #title><el-icon><List /></el-icon><span></span></template>
<el-menu-item index="/services/im"><el-icon><ChatDotRound /></el-icon><span></span></el-menu-item>
<el-menu-item index="/services/push"><el-icon><Bell /></el-icon><span>线</span></el-menu-item>
<el-menu-item index="/services/update"><el-icon><Upload /></el-icon><span></span></el-menu-item>
</el-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item>
<el-menu-item index="/docs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu>
</el-drawer>
@ -78,7 +96,7 @@
</el-header>
<el-main class="main-content">
<router-view />
<router-view :key="$route.path" />
</el-main>
</el-container>
</el-container>
@ -88,7 +106,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRoute, useRouter } from 'vue-router'
import { Document, Grid, Lock, Menu, Odometer, User } from '@element-plus/icons-vue'
import { Bell, ChatDotRound, Document, Grid, List, Lock, Menu, Odometer, Upload, User } from '@element-plus/icons-vue'
const auth = useAuthStore()
const route = useRoute()
@ -96,19 +114,9 @@ const router = useRouter()
const isMobile = ref(false)
const drawerVisible = ref(false)
const navItems = computed(() => {
const items = [
{ path: '/dashboard', label: '控制台', icon: Odometer },
{ path: '/apps', label: '我的应用', icon: Grid },
{ path: '/security', label: '安全中心', icon: Lock },
{ path: '/docs', label: '接入文档', icon: Document },
{ path: '/operation-logs', label: '操作日志', icon: Document },
]
if (auth.user?.type === 'MAIN') {
items.push({ path: '/accounts', label: '子账号管理', icon: User })
}
return items
})
const openedMenus = computed(() =>
route.path.startsWith('/services/') ? ['services'] : [],
)
function updateViewport() {
isMobile.value = window.innerWidth < 768

查看文件

@ -205,10 +205,10 @@ function updateViewport() {
}
const pushConfig = reactive<Required<PushServiceConfig>>({
huawei: { appId: '', appSecret: '' },
xiaomi: { appId: '', appKey: '', appSecret: '', packageName: '' },
oppo: { appId: '', appKey: '', masterSecret: '' },
vivo: { appId: '', appKey: '', appSecret: '' },
huawei: { appId: '', appSecret: '', category: '' },
xiaomi: { appId: '', appKey: '', appSecret: '', channelId: '' },
oppo: { appId: '', appKey: '', masterSecret: '', channelId: '' },
vivo: { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' },
honor: { appId: '', clientId: '', clientSecret: '' },
harmony: { appId: '', appSecret: '' },
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
@ -241,41 +241,45 @@ const vendorDefs: VendorDef[] = [
{
key: 'huawei',
label: '华为 HMS',
hint: '填写 AppId / AppSecret,供服务端推送使用。',
hint: '填写 AppId / AppSecret;Category 用于消息分类(如 IM。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret' },
{ key: 'category', label: 'Category', placeholder: 'IM' },
],
},
{
key: 'xiaomi',
label: '小米 MiPush',
hint: '填写 AppId / AppKey / AppSecret 及 Android 包名。',
hint: '填写 AppId / AppKey / AppSecret;Android 包名在应用设置中统一配置。Channel ID 为小米通知通道 ID如 118060。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' },
{ key: 'packageName', label: 'Android 包名', placeholder: 'com.example.app' },
{ key: 'channelId', label: 'Channel ID', placeholder: '118060' },
],
},
{
key: 'oppo',
label: 'OPPO 推送',
hint: '填写 AppId / AppKey / MasterSecret。',
hint: '填写 AppId / AppKey / MasterSecret;Channel ID 为 OPPO 推送通道 ID如 IM。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'masterSecret', label: 'MasterSecret' },
{ key: 'channelId', label: 'Channel ID', placeholder: 'IM' },
],
},
{
key: 'vivo',
label: 'vivo 推送',
hint: '填写 AppId / AppKey / AppSecret。',
hint: '填写 AppId / AppKey / AppSecret;Category 用于消息分类IM 代表 IM 消息);回执 ID 为 vivo 控制台预注册的消息回执标识。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret' },
{ key: 'category', label: 'Category', placeholder: 'IM' },
{ key: 'receiptId', label: '回执 ID', placeholder: '4470' },
],
},
{
@ -354,10 +358,10 @@ function applyConfig(raw?: string | null) {
}
function resetPushConfig() {
pushConfig.huawei = { appId: '', appSecret: '' }
pushConfig.xiaomi = { appId: '', appKey: '', appSecret: '', packageName: '' }
pushConfig.oppo = { appId: '', appKey: '', masterSecret: '' }
pushConfig.vivo = { appId: '', appKey: '', appSecret: '' }
pushConfig.huawei = { appId: '', appSecret: '', category: '' }
pushConfig.xiaomi = { appId: '', appKey: '', appSecret: '', channelId: '' }
pushConfig.oppo = { appId: '', appKey: '', masterSecret: '', channelId: '' }
pushConfig.vivo = { appId: '', appKey: '', appSecret: '', category: '', receiptId: '' }
pushConfig.honor = { appId: '', clientId: '', clientSecret: '' }
pushConfig.harmony = { appId: '', appSecret: '' }
pushConfig.apns = { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false }
@ -394,15 +398,20 @@ function toPushConfigRequest(): Record<string, unknown> {
return {
huaweiAppId: pushConfig.huawei.appId ?? '',
huaweiAppSecret: pushConfig.huawei.appSecret ?? '',
huaweiCategory: pushConfig.huawei.category ?? '',
xiaomiAppId: pushConfig.xiaomi.appId ?? '',
xiaomiAppKey: pushConfig.xiaomi.appKey ?? '',
xiaomiAppSecret: pushConfig.xiaomi.appSecret ?? '',
xiaomiChannelId: pushConfig.xiaomi.channelId ?? '',
oppoAppId: pushConfig.oppo.appId ?? '',
oppoAppKey: pushConfig.oppo.appKey ?? '',
oppoMasterSecret: pushConfig.oppo.masterSecret ?? '',
oppoChannelId: pushConfig.oppo.channelId ?? '',
vivoAppId: pushConfig.vivo.appId ?? '',
vivoAppKey: pushConfig.vivo.appKey ?? '',
vivoAppSecret: pushConfig.vivo.appSecret ?? '',
vivoCategory: pushConfig.vivo.category ?? '',
vivoReceiptId: pushConfig.vivo.receiptId ?? '',
honorAppId: pushConfig.honor.appId ?? '',
honorClientId: pushConfig.honor.clientId ?? '',
honorClientSecret: pushConfig.honor.clientSecret ?? '',

查看文件

@ -1,7 +1,15 @@
<template>
<div>
<el-page-header @back="$router.back()" content="推送管理" style="margin-bottom:24px" />
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">离线推送管理</span>
<el-select :model-value="appId" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.appKey" :label="a.name" :value="a.appKey" />
</el-select>
</div>
<el-page-header v-else @back="$router.back()" content="推送管理" style="margin-bottom:24px" />
<el-empty v-if="isServicesPortal && !appId" description="请选择一个应用" style="margin-top:80px" />
<template v-if="!isServicesPortal || appId">
<el-card style="margin-bottom:16px">
<template #header>用户设备状态查询</template>
<el-form inline @submit.prevent="queryUser">
@ -56,7 +64,7 @@
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="160">
<template #default="{ row }">{{ row.lastLoginAt ?? '-' }}</template>
<template #default="{ row }">{{ row.lastLoginAt ? formatDateTime(row.lastLoginAt) : '-' }}</template>
</el-table-column>
</el-table>
@ -135,17 +143,22 @@
/>
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
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'
const route = useRoute()
const router = useRouter()
const appId = route.params.appId as string
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
const portalApps = ref<App[]>([])
const isMobile = ref(window.innerWidth < 768)
function updateViewport() { isMobile.value = window.innerWidth < 768 }
@ -166,6 +179,10 @@ const logsPageSize = 20
const logsTotal = ref(0)
const logsTotalPages = ref(0)
function switchApp(val: string) {
router.push(`/services/push/${val}`)
}
async function queryUser() {
const uid = queryUserId.value.trim()
if (!uid) {
@ -229,6 +246,29 @@ function formatTime(ms: number): string {
return new Date(ms).toLocaleString('zh-CN')
}
onMounted(() => window.addEventListener('resize', updateViewport))
function formatDateTime(iso: string): string {
if (!iso) return '-'
return new Date(iso).toLocaleString('zh-CN')
}
onMounted(() => {
if (isServicesPortal.value) {
appApi.list().then(res => { portalApps.value = res.data.data })
}
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
</script>
<style scoped>
.portal-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.portal-bar-title {
font-size: 16px;
font-weight: 600;
}
</style>

查看文件

@ -1,7 +1,15 @@
<template>
<div>
<el-page-header @back="$router.back()" :content="`版本管理 — ${pageTitle}`" style="margin-bottom:20px" />
<div v-if="isServicesPortal" class="portal-bar">
<span class="portal-bar-title">版本管理</span>
<el-select :model-value="appId" placeholder="选择应用" style="width:220px" @change="switchApp">
<el-option v-for="a in portalApps" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</div>
<el-page-header v-else @back="$router.back()" :content="`版本管理 — ${pageTitle}`" style="margin-bottom:20px" />
<el-empty v-if="isServicesPortal && !appId" description="请选择一个应用" style="margin-top:80px" />
<template v-if="!isServicesPortal || appId">
<el-card>
<el-tabs v-model="activeTab">
<!-- App Versions -->
@ -610,12 +618,13 @@
</template>
</el-dialog>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type App } from '@/api/app'
import { fileApi } from '@/api/file'
@ -639,7 +648,10 @@ import vivoGuideImage from '@/assets/update-store/vivo/01.png'
import honorGuideImage from '@/assets/update-store/honor/01.png'
const route = useRoute()
const router = useRouter()
const appId = route.params.appId as string
const isServicesPortal = computed(() => route.path.startsWith('/services/'))
const portalApps = ref<App[]>([])
const app = ref<App | null>(null)
const pageTitle = computed(() => app.value?.name ?? appId)
const isMobile = ref(false)
@ -922,6 +934,10 @@ async function loadStoreConfigs() {
}
}
function switchApp(val: string) {
router.push(`/services/update/${val}`)
}
async function loadApp() {
const res = await appApi.get(appId)
app.value = res.data.data
@ -1700,6 +1716,10 @@ watch(grayForm, () => {
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
if (isServicesPortal.value) {
appApi.list().then(res => { portalApps.value = res.data.data })
if (!appId) return
}
loadApp()
loadAppVersions()
loadRnBundles()
@ -1900,4 +1920,15 @@ onBeforeUnmount(() => {
margin: 8px auto;
}
}
.portal-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.portal-bar-title {
font-size: 16px;
font-weight: 600;
}
</style>