docs(deploy): 添加生产环境部署配置示例和部署文档

- 新增 .env.production.example 环境变量配置模板
- 添加 compose.production.yaml Docker Compose 部署配置
- 创建 web.Dockerfile 前端构建部署文件
- 编写详细的 README.md 部署文档,涵盖架构、配置、步骤等内容
- 添加离线推送架构设计文档
- 更新 IM 多平台进度跟踪文档
这个提交包含在:
XuqmGroup 2026-04-30 09:49:05 +08:00
父节点 e47f510a0b
当前提交 c2ff993e05
共有 21 个文件被更改,包括 1773 次插入280 次删除

查看文件

@ -13,6 +13,7 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader'] ElHeader: typeof import('element-plus/es')['ElHeader']

查看文件

@ -1,6 +1,6 @@
<template> <template>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5"> <div class="login-page">
<el-card style="width:360px"> <el-card class="login-card">
<h2 style="text-align:center;margin-bottom:24px">运营平台登录</h2> <h2 style="text-align:center;margin-bottom:24px">运营平台登录</h2>
<el-form :model="form" @submit.prevent="login"> <el-form :model="form" @submit.prevent="login">
<el-form-item> <el-form-item>
@ -38,3 +38,34 @@ async function login() {
} }
} }
</script> </script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background:
radial-gradient(circle at top left, rgba(15, 23, 42, 0.12), transparent 28%),
linear-gradient(180deg, #f4f7fb 0%, #e8edf5 100%);
}
.login-card {
width: min(360px, calc(100vw - 32px));
box-sizing: border-box;
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
@media (max-width: 767px) {
.login-page {
align-items: flex-start;
padding-top: 16vh;
}
.login-card {
width: 100%;
}
}
</style>

查看文件

@ -1,29 +1,194 @@
<template> <template>
<el-container style="height:100vh"> <el-container class="layout-container">
<el-aside width="200px" style="background:#1d2129"> <el-aside v-if="!isMobile" class="sidebar" width="200px">
<div style="height:60px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px;border-bottom:1px solid #2d3142"> <div class="logo">
XuqmGroup 运营平台 XuqmGroup 运营平台
</div> </div>
<el-menu router :default-active="$route.path" background-color="#1d2129" text-color="#c9d1d9" active-text-color="#409eff"> <el-menu
<el-menu-item index="/tenants"><el-icon><Avatar /></el-icon></el-menu-item> router
<el-menu-item index="/statistics"><el-icon><TrendCharts /></el-icon></el-menu-item> :default-active="$route.path"
<el-menu-item index="/service-requests"><el-icon><Bell /></el-icon></el-menu-item> 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> </el-menu>
</el-aside> </el-aside>
<el-container>
<el-header style="background:#fff;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:flex-end"> <el-drawer
<el-button link @click="logout">退出登录</el-button> v-model="drawerVisible"
direction="ltr"
:with-header="false"
size="240px"
class="mobile-drawer"
>
<div class="drawer-brand">
XuqmGroup 运营平台
</div>
<el-menu
router
:default-active="$route.path"
background-color="#1d2129"
text-color="#c9d1d9"
active-text-color="#409eff"
class="nav-menu"
@select="drawerVisible = false"
>
<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>
</el-drawer>
<el-container class="content-shell">
<el-header class="header">
<div v-if="isMobile" class="mobile-header-left">
<el-button text circle class="menu-button" @click="drawerVisible = true">
<el-icon><Menu /></el-icon>
</el-button>
<span class="mobile-title">运营平台</span>
</div>
<div v-else class="header-spacer" />
<el-button link class="logout-button" @click="logout">退出登录</el-button>
</el-header> </el-header>
<el-main><router-view /></el-main> <el-main class="main-content">
<router-view />
</el-main>
</el-container> </el-container>
</el-container> </el-container>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Avatar, Bell, Menu, TrendCharts } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const isMobile = ref(false)
const drawerVisible = ref(false)
const navItems = computed(() => [
{ path: '/tenants', label: '租户管理', icon: Avatar },
{ path: '/statistics', label: '数据统计', icon: TrendCharts },
{ path: '/service-requests', label: '服务开通审核', icon: Bell },
])
function logout() { function logout() {
localStorage.removeItem('ops_token') localStorage.removeItem('ops_token')
router.push('/login') router.push('/login')
} }
function updateViewport() {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) {
drawerVisible.value = false
}
}
watch(
() => router.currentRoute.value.path,
() => {
drawerVisible.value = false
},
)
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script> </script>
<style scoped>
.layout-container {
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
}
.sidebar {
background: #1d2129;
flex: 0 0 200px;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.08);
}
.logo,
.drawer-brand {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: bold;
font-size: 16px;
border-bottom: 1px solid #2d3142;
}
.nav-menu {
border-right: none;
}
.content-shell {
min-width: 0;
}
.header {
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 16px;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
position: sticky;
top: 0;
z-index: 9;
}
.header-spacer {
flex: 1;
}
.mobile-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-title {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.menu-button {
color: #111827;
}
.logout-button {
flex-shrink: 0;
}
.main-content {
padding: 16px;
overflow: auto;
}
.mobile-drawer :deep(.el-drawer__body) {
padding: 0;
background: #1d2129;
}
@media (max-width: 767px) {
.layout-container {
display: block;
min-height: 100vh;
height: auto;
}
.header {
padding: 0 12px;
}
.main-content {
padding: 12px;
}
}
</style>

查看文件

@ -23,6 +23,7 @@ declare module 'vue' {
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider'] ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']

查看文件

@ -56,6 +56,31 @@ export interface UpdateServiceConfig {
defaultMarketUrl?: string defaultMarketUrl?: string
} }
export interface PushVendorConfig {
appId?: string
appKey?: string
appSecret?: string
masterSecret?: string
clientId?: string
clientSecret?: string
teamId?: string
keyId?: string
bundleId?: string
keyPath?: string
sandbox?: boolean
serviceAccountJson?: string
}
export interface PushServiceConfig {
huawei?: PushVendorConfig
xiaomi?: PushVendorConfig
oppo?: PushVendorConfig
vivo?: PushVendorConfig
honor?: PushVendorConfig
apns?: PushVendorConfig
fcm?: PushVendorConfig
}
export const appApi = { export const appApi = {
list: () => client.get<{ data: App[] }>('/apps'), list: () => client.get<{ data: App[] }>('/apps'),
@ -84,7 +109,7 @@ export const appApi = {
appId: string, appId: string,
platform: string, platform: string,
serviceType: string, serviceType: string,
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig>, config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig>,
) => ) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, { client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
params: { platform, serviceType }, params: { platform, serviceType },

查看文件

@ -1,6 +1,7 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
import { isJwtExpired } from '@/utils/jwt'
const client = axios.create({ const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api', baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
@ -27,22 +28,41 @@ if (import.meta.env.DEV) {
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token && !isJwtExpired(token)) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} else if (token && isJwtExpired(token)) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
return Promise.reject(new Error('登录已失效,请重新登录'))
} }
return config return config
}) })
function handleAuthFailure(message: string) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
ElMessage.error(message)
}
client.interceptors.response.use( client.interceptors.response.use(
(res) => res, (res) => res,
(error) => { (error) => {
if (error.response?.status === 401) { const status = error.response?.status
localStorage.removeItem('token') if (status === 401) {
router.push('/login') handleAuthFailure('登录已失效,请重新登录')
} else { return Promise.reject(error)
const msg = error.response?.data?.message ?? '请求失败'
ElMessage.error(msg)
} }
if (status === 403) {
const msg = error.response?.data?.message ?? '当前账号无权限访问该资源'
ElMessage.error(msg)
return Promise.reject(error)
}
const msg = error.response?.data?.message ?? '请求失败'
ElMessage.error(msg)
return Promise.reject(error) return Promise.reject(error)
}, },
) )

查看文件

@ -1,7 +1,7 @@
import axios from 'axios' import axios from 'axios'
const imClient = axios.create({ const imClient = axios.create({
baseURL: 'http://192.168.116.9:8082', baseURL: import.meta.env.VITE_IM_API_BASE_URL ?? 'http://192.168.116.9:8082',
timeout: 15000, timeout: 15000,
}) })

查看文件

@ -0,0 +1,29 @@
import client from './client'
export interface TenantOperationLog {
id: string
tenantId: string
moduleType: string
resourceType: string
resourceId?: string
action: string
operator?: string
detailJson?: string
createdAt: string
}
export interface PageResult<T> {
content: T[]
number: number
size: number
totalElements: number
totalPages: number
first: boolean
last: boolean
}
export const operationLogApi = {
list(params: { moduleType?: string; page?: number; size?: number }) {
return client.get<{ data: PageResult<TenantOperationLog> }>('/operation-logs', { params })
},
}

查看文件

@ -1,7 +1,8 @@
import axios from 'axios' import axios from 'axios'
import { isJwtExpired } from '@/utils/jwt'
const updateClient = axios.create({ const updateClient = axios.create({
baseURL: 'http://192.168.116.9:8084', baseURL: import.meta.env.VITE_UPDATE_API_BASE_URL ?? '',
timeout: 30000, timeout: 30000,
}) })
@ -34,11 +35,37 @@ updateClient.interceptors.request.use((config) => {
) )
if (!skipAuth) { if (!skipAuth) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}` if (token && !isJwtExpired(token)) {
config.headers.Authorization = `Bearer ${token}`
} else if (token && isJwtExpired(token)) {
localStorage.removeItem('token')
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = `/login?reason=${encodeURIComponent('登录已失效,请重新登录')}`
}
return Promise.reject(new Error('登录已失效,请重新登录'))
}
} }
return config return config
}) })
updateClient.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status
if (status === 401) {
localStorage.removeItem('token')
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = `/login?reason=${encodeURIComponent('登录已失效,请重新登录')}`
}
return Promise.reject(error)
}
if (status === 403) {
return Promise.reject(error)
}
return Promise.reject(error)
},
)
export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK' export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE' | 'GOOGLE_PLAY' | 'HARMONY_APP' | 'REVIEW_WEBHOOK'
export type StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED' export type StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW' export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
@ -52,6 +79,18 @@ export interface PublishConfig {
updatedAt: string updatedAt: string
} }
export interface OperationLog {
id: string
appId: string
resourceType: string
resourceId: string
action: string
operator?: string
reason?: string
detailJson?: string
createdAt: string
}
export interface GrayMember { export interface GrayMember {
userId: string userId: string
name?: string name?: string
@ -179,8 +218,8 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/updates/app/${id}/publish`, body ?? {}) return updateClient.post(`/api/v1/updates/app/${id}/publish`, body ?? {})
}, },
unpublishAppVersion(id: string) { unpublishAppVersion(id: string, reason: string) {
return updateClient.post(`/api/v1/updates/app/${id}/unpublish`) return updateClient.post(`/api/v1/updates/app/${id}/unpublish`, { reason })
}, },
grayAppVersion(id: string, body: { grayAppVersion(id: string, body: {
@ -213,8 +252,8 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/rn/${id}/publish`, body ?? {}) return updateClient.post(`/api/v1/rn/${id}/publish`, body ?? {})
}, },
unpublishRnBundle(id: string) { unpublishRnBundle(id: string, reason: string) {
return updateClient.post(`/api/v1/rn/${id}/unpublish`) return updateClient.post(`/api/v1/rn/${id}/unpublish`, { reason })
}, },
grayRnBundle(id: string, body: { grayRnBundle(id: string, body: {
@ -285,6 +324,12 @@ export const updateAdminApi = {
return updateClient.put<{ data: PublishConfig }>('/api/v1/updates/publish/config', config, { params: { appId } }) return updateClient.put<{ data: PublishConfig }>('/api/v1/updates/publish/config', config, { params: { appId } })
}, },
listOperationLogs(appId: string, limit = 100) {
return updateClient.get<{ data: OperationLog[] }>('/api/v1/updates/ops/logs', {
params: { appId, limit },
})
},
listGrayMembers(appId: string, keyword?: string, groupName?: string) { listGrayMembers(appId: string, keyword?: string, groupName?: string) {
return updateClient.get<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members', { return updateClient.get<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members', {
params: { appId, ...(keyword && { keyword }), ...(groupName && { groupName }) }, params: { appId, ...(keyword && { keyword }), ...(groupName && { groupName }) },

查看文件

@ -33,6 +33,10 @@ const router = createRouter({
path: 'apps', path: 'apps',
component: () => import('@/views/apps/AppListView.vue'), component: () => import('@/views/apps/AppListView.vue'),
}, },
{
path: 'operation-logs',
component: () => import('@/views/logs/OperationLogView.vue'),
},
{ {
path: 'apps/:id', path: 'apps/:id',
component: () => import('@/views/apps/AppDetailView.vue'), component: () => import('@/views/apps/AppDetailView.vue'),
@ -41,6 +45,10 @@ const router = createRouter({
path: 'apps/:appId/im-config', path: 'apps/:appId/im-config',
component: () => import('@/views/im/ImConfigView.vue'), component: () => import('@/views/im/ImConfigView.vue'),
}, },
{
path: 'apps/:appId/push-config',
component: () => import('@/views/push/PushConfigView.vue'),
},
{ {
path: 'apps/:appId/im-webhooks', path: 'apps/:appId/im-webhooks',
component: () => import('@/views/im/ImWebhookView.vue'), component: () => import('@/views/im/ImWebhookView.vue'),

查看文件

@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { decodeJwtPayload, isJwtExpired } from '@/utils/jwt'
export interface UserInfo { export interface UserInfo {
id: string id: string
@ -20,15 +21,20 @@ export const useAuthStore = defineStore('auth', () => {
function parseUser(t: string) { function parseUser(t: string) {
try { try {
const payload = JSON.parse(atob(t.split('.')[1])) const payload = decodeJwtPayload(t)
if (!payload || isJwtExpired(t)) {
throw new Error('token expired')
}
user.value = { user.value = {
id: payload.sub, id: String(payload.sub ?? ''),
username: payload.username, username: String(payload.username ?? ''),
nickname: payload.nickname, nickname: String(payload.nickname ?? ''),
type: payload.type, type: payload.type === 'SUB' ? 'SUB' : 'MAIN',
} }
} catch { } catch {
user.value = null user.value = null
token.value = null
localStorage.removeItem('token')
} }
} }

查看文件

@ -0,0 +1,20 @@
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
return JSON.parse(atob(padded)) as Record<string, unknown>
} catch {
return null
}
}
export function isJwtExpired(token: string): boolean {
const payload = decodeJwtPayload(token)
const exp = typeof payload?.exp === 'number' ? payload.exp : Number(payload?.exp)
if (!Number.isFinite(exp)) {
return false
}
return Date.now() >= exp * 1000
}

查看文件

@ -2,8 +2,8 @@
<div v-if="app"> <div v-if="app">
<el-page-header @back="$router.back()" :content="app.name" style="margin-bottom:24px" /> <el-page-header @back="$router.back()" :content="app.name" style="margin-bottom:24px" />
<el-card style="margin-bottom:16px"> <el-card class="info-card" style="margin-bottom:16px">
<el-descriptions :column="2" border> <el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item> <el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item> <el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="AppKey"> <el-descriptions-item label="AppKey">
@ -24,7 +24,7 @@
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card style="margin-bottom:16px"> <el-card class="info-card" style="margin-bottom:16px">
<template #header>即时通讯服务</template> <template #header>即时通讯服务</template>
<div class="service-grid"> <div class="service-grid">
<el-card class="service-card"> <el-card class="service-card">
@ -54,41 +54,76 @@
</div> </div>
</el-card> </el-card>
<el-card> <el-card class="info-card">
<template #header>离线推送与版本管理</template> <template #header>离线推送</template>
<div class="service-grid"> <div class="service-grid">
<el-card v-for="svcType in ['PUSH', 'UPDATE']" :key="svcType" class="service-card"> <el-card class="service-card">
<div class="service-header"> <div class="service-header">
<div class="service-title-block"> <div class="service-title-block">
<span class="service-name">{{ serviceLabel(svcType) }}</span> <span class="service-name">{{ serviceLabel('PUSH') }}</span>
<span class="service-help">{{ serviceHelp(svcType) }}</span> <span class="service-help">{{ serviceHelp('PUSH') }}</span>
</div> </div>
<el-switch <el-switch
:model-value="isServiceEnabled(svcType)" :model-value="isServiceEnabled('PUSH')"
@change="(val: boolean) => onToggleService(svcType, val)" @change="(val: boolean) => onToggleService('PUSH', val)"
/> />
</div> </div>
<div class="service-status-row"> <div class="service-status-row">
<el-tag :type="isServiceEnabled(svcType) ? 'success' : 'info'" size="small"> <el-tag :type="isServiceEnabled('PUSH') ? 'success' : 'info'" size="small">
{{ isServiceEnabled(svcType) ? '已开通' : '未开通' }} {{ isServiceEnabled('PUSH') ? '已开通' : '未开通' }}
</el-tag> </el-tag>
<span class="service-status-text"> <span class="service-status-text">
{{ svcType === 'UPDATE' 推送服务开通后即可在终端接收设备级推送
? 'Android 整包版本在版本管理页上传;iOS / 鸿蒙仅记录版本号和市场跳转页。商店配置与发布配置都在版本管理页。'
: '推送服务开通后即可在终端接收设备级推送。' }}
</span> </span>
</div> </div>
<div class="service-actions" v-if="svcType === 'UPDATE'"> <div class="service-actions">
<el-button size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/update`)"> <el-button v-if="isServiceEnabled('PUSH')" size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/push-config`)">
推送配置
</el-button>
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('PUSH')">
申请开通
</el-button>
</div>
</el-card>
</div>
</el-card>
<el-card class="info-card" style="margin-top:16px">
<template #header>版本管理</template>
<div class="service-grid">
<el-card class="service-card">
<div class="service-header">
<div class="service-title-block">
<span class="service-name">{{ serviceLabel('UPDATE') }}</span>
<span class="service-help">{{ serviceHelp('UPDATE') }}</span>
</div>
<el-switch
:model-value="isServiceEnabled('UPDATE')"
@change="(val: boolean) => onToggleService('UPDATE', val)"
/>
</div>
<div class="service-status-row">
<el-tag :type="isServiceEnabled('UPDATE') ? 'success' : 'info'" size="small">
{{ isServiceEnabled('UPDATE') ? '已开通' : '未开通' }}
</el-tag>
<span class="service-status-text">
Android 整包版本在版本管理页上传iOS / 鸿蒙仅记录版本号和市场跳转页商店配置与发布配置都在版本管理页
</span>
</div>
<div class="service-actions">
<el-button v-if="isServiceEnabled('UPDATE')" size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/update`)">
版本管理 版本管理
</el-button> </el-button>
<el-button v-else size="small" type="primary" plain @click="openActivationRequest('UPDATE')">
申请开通
</el-button>
</div> </div>
</el-card> </el-card>
</div> </div>
</el-card> </el-card>
<!-- Email Verify Dialog (reveal or reset) --> <!-- Email Verify Dialog (reveal or reset) -->
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" width="420px"> <el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" :width="dialogWidth">
<div v-if="!codeSent"> <div v-if="!codeSent">
<p style="color:#555;margin-bottom:16px"> <p style="color:#555;margin-bottom:16px">
{{ verifyPurpose === 'REVEAL_SECRET' {{ verifyPurpose === 'REVEAL_SECRET'
@ -115,7 +150,7 @@
</el-dialog> </el-dialog>
<!-- Activation Request Dialog --> <!-- Activation Request Dialog -->
<el-dialog v-model="showActivationDialog" title="申请开通服务" width="420px"> <el-dialog v-model="showActivationDialog" title="申请开通服务" :width="dialogWidth">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="服务">{{ serviceLabel(activationForm.serviceType) }}</el-form-item> <el-form-item label="服务">{{ serviceLabel(activationForm.serviceType) }}</el-form-item>
<el-form-item label="申请理由"> <el-form-item label="申请理由">
@ -131,7 +166,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue' import { View } from '@element-plus/icons-vue'
@ -143,6 +178,8 @@ const app = ref<App | null>(null)
const services = ref<FeatureService[]>([]) const services = ref<FeatureService[]>([])
const imService = computed(() => services.value.find(s => s.serviceType === 'IM') ?? null) const imService = computed(() => services.value.find(s => s.serviceType === 'IM') ?? null)
const imEnabled = computed(() => imService.value?.enabled ?? false) const imEnabled = computed(() => imService.value?.enabled ?? false)
const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '420px'))
const revealedSecret = ref<string | null>(null) const revealedSecret = ref<string | null>(null)
@ -168,8 +205,8 @@ function serviceLabel(type: string) {
function serviceHelp(type: string) { function serviceHelp(type: string) {
return { return {
IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。', IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。',
PUSH: '一次开通后,推送配置在服务管理页按平台维护。', PUSH: '一次开通后,可在推送配置页按厂商维护配置。',
UPDATE: '一次开通后,版本管理页只管理 Android 整包版本,iOS / 鸿蒙仅记录提醒信息。', UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。',
}[type] ?? '' }[type] ?? ''
} }
@ -273,11 +310,26 @@ function copy(text: string) {
ElMessage.success('已复制') ElMessage.success('已复制')
} }
onMounted(loadData) function updateViewport() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
loadData()
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script> </script>
<style scoped> <style scoped>
.mono { font-family: monospace; font-size: 12px; } .mono { font-family: monospace; font-size: 12px; }
.info-card :deep(.el-descriptions__body) {
overflow-x: auto;
}
.service-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 16px; } .service-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 16px; }
.service-card { border: 1px solid #e8e8e8; } .service-card { border: 1px solid #e8e8e8; }
.service-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; font-weight: 500; } .service-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; font-weight: 500; }
@ -287,4 +339,26 @@ onMounted(loadData)
.service-status-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; } .service-status-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.service-status-text { font-size: 13px; color: #6b7280; line-height: 1.5; } .service-status-text { font-size: 13px; color: #6b7280; line-height: 1.5; }
.service-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; } .service-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 767px) {
.service-grid {
grid-template-columns: 1fr;
}
.service-header {
flex-direction: column;
}
.service-actions {
flex-direction: column;
}
.service-actions :deep(.el-button) {
width: 100%;
}
.info-card :deep(.el-descriptions__table) {
min-width: 100%;
}
}
</style> </style>

查看文件

@ -7,22 +7,24 @@
</el-button> </el-button>
</div> </div>
<el-table :data="apps" v-loading="loading" style="width:100%"> <div class="table-wrap">
<el-table-column prop="name" label="应用名称" /> <el-table :data="apps" v-loading="loading" style="width:100%">
<el-table-column prop="packageName" label="包名" /> <el-table-column prop="name" label="应用名称" min-width="120" />
<el-table-column prop="appKey" label="AppKey" show-overflow-tooltip /> <el-table-column prop="packageName" label="包名" min-width="160" />
<el-table-column prop="createdAt" label="创建时间" width="180"> <el-table-column prop="appKey" label="AppKey" min-width="220" show-overflow-tooltip />
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template> <el-table-column prop="createdAt" label="创建时间" width="180">
</el-table-column> <template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
<el-table-column label="操作" width="180"> </el-table-column>
<template #default="{ row }"> <el-table-column label="操作" width="180">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button> <template #default="{ row }">
<el-button link type="danger" @click="handleDelete(row.id)">删除</el-button> <el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button>
</template> <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
</div>
<el-dialog v-model="showCreate" title="创建应用" width="480px"> <el-dialog v-model="showCreate" title="创建应用" :width="dialogWidth">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top"> <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top">
<el-form-item label="包名" prop="packageName"> <el-form-item label="包名" prop="packageName">
<el-input v-model="createForm.packageName" placeholder="com.example.app" /> <el-input v-model="createForm.packageName" placeholder="com.example.app" />
@ -43,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { appApi, type App } from '@/api/app' import { appApi, type App } from '@/api/app'
@ -53,6 +55,8 @@ const loading = ref(false)
const showCreate = ref(false) const showCreate = ref(false)
const creating = ref(false) const creating = ref(false)
const createFormRef = ref<FormInstance>() 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: '', name: '', description: '' })
const createRules: FormRules = { const createRules: FormRules = {
@ -94,9 +98,43 @@ function formatDate(d: string) {
return d ? new Date(d).toLocaleString('zh-CN') : '-' return d ? new Date(d).toLocaleString('zh-CN') : '-'
} }
onMounted(loadApps) function updateViewport() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
loadApps()
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script> </script>
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.table-wrap {
overflow-x: auto;
background: #fff;
border-radius: 12px;
}
@media (max-width: 767px) {
.page-header {
align-items: stretch;
}
.page-header :deep(.el-button) {
width: 100%;
}
}
</style> </style>

查看文件

@ -90,10 +90,18 @@ onMounted(loadCaptcha)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 16px;
background:
radial-gradient(circle at top left, rgba(102, 126, 234, 0.35), transparent 35%),
radial-gradient(circle at bottom right, rgba(118, 75, 162, 0.32), transparent 32%),
linear-gradient(135deg, #667eea 0%, #764ba2 100%);
} }
.login-card { .login-card {
width: 400px; width: min(420px, calc(100vw - 32px));
box-sizing: border-box;
border-radius: 18px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(8px);
} }
.title { .title {
text-align: center; text-align: center;
@ -117,4 +125,33 @@ onMounted(loadCaptcha)
justify-content: space-between; justify-content: space-between;
font-size: 14px; font-size: 14px;
} }
@media (max-width: 767px) {
.login-page {
align-items: flex-start;
padding-top: 12vh;
}
.login-card {
width: 100%;
}
.captcha-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.captcha-img {
width: 100%;
height: 44px;
object-fit: cover;
}
.links {
flex-direction: column;
gap: 8px;
align-items: center;
}
}
</style> </style>

查看文件

@ -2,8 +2,8 @@
<div> <div>
<el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appKey}`" style="margin-bottom:20px" /> <el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appKey}`" style="margin-bottom:20px" />
<el-row :gutter="16" style="margin-bottom:20px"> <el-row :gutter="16" class="stat-grid">
<el-col :span="6" v-for="item in statCards" :key="item.label"> <el-col :xs="24" :sm="12" :md="6" v-for="item in statCards" :key="item.label">
<el-card shadow="never"> <el-card shadow="never">
<div class="stat-card"> <div class="stat-card">
<span class="stat-value">{{ item.value }}</span> <span class="stat-value">{{ item.value }}</span>
@ -16,11 +16,12 @@
<el-card shadow="never"> <el-card shadow="never">
<el-tabs v-model="activeTab" @tab-change="handleTabChange"> <el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="注册用户" name="users"> <el-tab-pane label="注册用户" name="users">
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-button type="primary" @click="openCreateUserDialog">新增用户</el-button> <el-button type="primary" @click="openCreateUserDialog">新增用户</el-button>
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button> <el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
</div> </div>
<div class="table-wrap">
<el-table :data="users" v-loading="loadingUsers" border stripe> <el-table :data="users" v-loading="loadingUsers" border stripe>
<el-table-column prop="userId" label="用户ID" width="180" /> <el-table-column prop="userId" label="用户ID" width="180" />
<el-table-column label="头像" width="90"> <el-table-column label="头像" width="90">
@ -64,6 +65,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<el-pagination <el-pagination
v-if="userTotal > userPageSize" v-if="userTotal > userPageSize"
@ -77,11 +79,12 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="群组列表" name="groups"> <el-tab-pane label="群组列表" name="groups">
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-button type="primary" @click="openCreateGroupDialog">创建群组</el-button> <el-button type="primary" @click="openCreateGroupDialog">创建群组</el-button>
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button> <el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
</div> </div>
<div class="table-wrap">
<el-table :data="groups" v-loading="loadingGroups" border stripe> <el-table :data="groups" v-loading="loadingGroups" border stripe>
<el-table-column prop="id" label="群组ID" width="240" /> <el-table-column prop="id" label="群组ID" width="240" />
<el-table-column prop="name" label="群名称" min-width="160" /> <el-table-column prop="name" label="群名称" min-width="160" />
@ -115,6 +118,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="内容治理" name="governance"> <el-tab-pane label="内容治理" name="governance">
@ -138,13 +142,14 @@
<el-divider /> <el-divider />
<div class="toolbar toolbar-space-between"> <div class="toolbar toolbar-space-between responsive-toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<el-button type="primary" @click="openKeywordFilterDialog">新增过滤规则</el-button> <el-button type="primary" @click="openKeywordFilterDialog">新增过滤规则</el-button>
<el-button @click="loadKeywordFilters" :loading="loadingKeywordFilters">刷新</el-button> <el-button @click="loadKeywordFilters" :loading="loadingKeywordFilters">刷新</el-button>
</div> </div>
</div> </div>
<div class="table-wrap">
<el-table :data="keywordFilters" v-loading="loadingKeywordFilters" border stripe> <el-table :data="keywordFilters" v-loading="loadingKeywordFilters" border stripe>
<el-table-column prop="pattern" label="命中词" min-width="180" /> <el-table-column prop="pattern" label="命中词" min-width="180" />
<el-table-column prop="action" label="动作" width="110"> <el-table-column prop="action" label="动作" width="110">
@ -174,6 +179,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="消息历史" name="messages"> <el-tab-pane label="消息历史" name="messages">
@ -213,6 +219,7 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="table-wrap">
<el-table :data="messages" v-loading="loadingMessages" border stripe> <el-table :data="messages" v-loading="loadingMessages" border stripe>
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
@ -235,6 +242,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<el-pagination <el-pagination
v-if="historyTotal > historyPageSize" v-if="historyTotal > historyPageSize"
@ -248,10 +256,11 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="操作日志" name="logs"> <el-tab-pane label="操作日志" name="logs">
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-button @click="loadOperationLogs" :loading="loadingLogs">刷新</el-button> <el-button @click="loadOperationLogs" :loading="loadingLogs">刷新</el-button>
</div> </div>
<div class="table-wrap">
<el-table :data="operationLogs" v-loading="loadingLogs" border stripe> <el-table :data="operationLogs" v-loading="loadingLogs" border stripe>
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
@ -272,6 +281,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<el-pagination <el-pagination
v-if="operationLogTotal > operationLogPageSize" v-if="operationLogTotal > operationLogPageSize"
@ -293,30 +303,32 @@
<el-button text type="primary" @click="showWebhookGuideDialog = true">查看接入说明</el-button> <el-button text type="primary" @click="showWebhookGuideDialog = true">查看接入说明</el-button>
</div> </div>
<el-table :data="webhooks" v-loading="loadingWebhooks" border stripe> <div class="table-wrap">
<el-table-column prop="url" label="回调地址" min-width="260" show-overflow-tooltip /> <el-table :data="webhooks" v-loading="loadingWebhooks" border stripe>
<el-table-column prop="secret" label="密钥" width="120"> <el-table-column prop="url" label="回调地址" min-width="260" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column prop="secret" label="密钥" width="120">
{{ row.secret ? '******' : '-' }} <template #default="{ row }">
</template> {{ row.secret ? '******' : '-' }}
</el-table-column> </template>
<el-table-column prop="enabled" label="启用" width="100"> </el-table-column>
<template #default="{ row }"> <el-table-column prop="enabled" label="启用" width="100">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small"> <template #default="{ row }">
{{ row.enabled ? '启用' : '停用' }} <el-tag :type="row.enabled ? 'success' : 'info'" size="small">
</el-tag> {{ row.enabled ? '启用' : '停用' }}
</template> </el-tag>
</el-table-column> </template>
<el-table-column prop="createdAt" label="创建时间" width="180"> </el-table-column>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <el-table-column prop="createdAt" label="创建时间" width="180">
</el-table-column> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<el-table-column label="操作" width="180" fixed="right"> </el-table-column>
<template #default="{ row }"> <el-table-column label="操作" width="180" fixed="right">
<el-button link type="primary" size="small" @click="openEditWebhookDialog(row)">编辑</el-button> <template #default="{ row }">
<el-button link type="danger" size="small" @click="deleteWebhook(row)">删除</el-button> <el-button link type="primary" size="small" @click="openEditWebhookDialog(row)">编辑</el-button>
</template> <el-button link type="danger" size="small" @click="deleteWebhook(row)">删除</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
@ -486,58 +498,60 @@
</div> </div>
</div> </div>
<el-table :data="managedGroupMembers" v-loading="loadingManagedGroupMembers" border stripe> <div class="table-wrap">
<el-table-column prop="userId" label="用户ID" width="160" /> <el-table :data="managedGroupMembers" v-loading="loadingManagedGroupMembers" border stripe>
<el-table-column prop="nickname" label="昵称" min-width="140" /> <el-table-column prop="userId" label="用户ID" width="160" />
<el-table-column label="角色" width="120"> <el-table-column prop="nickname" label="昵称" min-width="140" />
<template #default="{ row }"> <el-table-column label="角色" width="120">
<el-tag :type="groupMemberRoleTagType(row.userId)" size="small"> <template #default="{ row }">
{{ groupMemberRoleLabel(row.userId) }} <el-tag :type="groupMemberRoleTagType(row.userId)" size="small">
</el-tag> {{ groupMemberRoleLabel(row.userId) }}
</template> </el-tag>
</el-table-column> </template>
<el-table-column prop="status" label="状态" width="100"> </el-table-column>
<template #default="{ row }"> <el-table-column prop="status" label="状态" width="100">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'" size="small"> <template #default="{ row }">
{{ row.status === 'ACTIVE' ? '正常' : '封禁' }} <el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
</el-tag> {{ row.status === 'ACTIVE' ? '正常' : '封禁' }}
</template> </el-tag>
</el-table-column> </template>
<el-table-column prop="createdAt" label="加入时间" width="180"> </el-table-column>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <el-table-column prop="createdAt" label="加入时间" width="180">
</el-table-column> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<el-table-column label="操作" width="260" fixed="right"> </el-table-column>
<template #default="{ row }"> <el-table-column label="操作" width="260" fixed="right">
<el-button <template #default="{ row }">
link <el-button
type="primary" link
size="small" type="primary"
:disabled="row.userId === managedGroup?.creatorId" size="small"
@click="toggleManagedGroupRole(row)" :disabled="row.userId === managedGroup?.creatorId"
> @click="toggleManagedGroupRole(row)"
{{ groupMemberRoleLabel(row.userId) === '管理员' ? '降为成员' : '设为管理员' }} >
</el-button> {{ groupMemberRoleLabel(row.userId) === '管理员' ? '降为成员' : '设为管理员' }}
<el-button </el-button>
link <el-button
type="warning" link
size="small" type="warning"
:disabled="row.userId === managedGroup?.creatorId" size="small"
@click="muteManagedGroupMember(row)" :disabled="row.userId === managedGroup?.creatorId"
> @click="muteManagedGroupMember(row)"
禁言 >
</el-button> 禁言
<el-button </el-button>
link <el-button
type="danger" link
size="small" type="danger"
:disabled="row.userId === managedGroup?.creatorId" size="small"
@click="removeManagedGroupMember(row)" :disabled="row.userId === managedGroup?.creatorId"
> @click="removeManagedGroupMember(row)"
移除 >
</el-button> 移除
</template> </el-button>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="入群申请" name="requests"> <el-tab-pane label="入群申请" name="requests">
@ -547,45 +561,47 @@
</div> </div>
</div> </div>
<el-table :data="managedGroupJoinRequests" v-loading="loadingManagedGroupJoinRequests" border stripe> <div class="table-wrap">
<el-table-column prop="requesterId" label="申请人" width="160" /> <el-table :data="managedGroupJoinRequests" v-loading="loadingManagedGroupJoinRequests" border stripe>
<el-table-column prop="remark" label="备注" min-width="240" show-overflow-tooltip /> <el-table-column prop="requesterId" label="申请人" width="160" />
<el-table-column prop="status" label="状态" width="110"> <el-table-column prop="remark" label="备注" min-width="240" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column prop="status" label="状态" width="110">
<el-tag :type="groupRequestTagType(row.status)" size="small"> <template #default="{ row }">
{{ groupRequestStatusLabel(row.status) }} <el-tag :type="groupRequestTagType(row.status)" size="small">
</el-tag> {{ groupRequestStatusLabel(row.status) }}
</template> </el-tag>
</el-table-column> </template>
<el-table-column prop="createdAt" label="申请时间" width="180"> </el-table-column>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <el-table-column prop="createdAt" label="申请时间" width="180">
</el-table-column> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<el-table-column prop="reviewedAt" label="处理时间" width="180"> </el-table-column>
<template #default="{ row }">{{ formatTime(row.reviewedAt) }}</template> <el-table-column prop="reviewedAt" label="处理时间" width="180">
</el-table-column> <template #default="{ row }">{{ formatTime(row.reviewedAt) }}</template>
<el-table-column label="操作" width="170" fixed="right"> </el-table-column>
<template #default="{ row }"> <el-table-column label="操作" width="170" fixed="right">
<el-button <template #default="{ row }">
link <el-button
type="primary" link
size="small" type="primary"
:disabled="row.status !== 'PENDING'" size="small"
@click="approveGroupJoin(row)" :disabled="row.status !== 'PENDING'"
> @click="approveGroupJoin(row)"
同意 >
</el-button> 同意
<el-button </el-button>
link <el-button
type="danger" link
size="small" type="danger"
:disabled="row.status !== 'PENDING'" size="small"
@click="declineGroupJoin(row)" :disabled="row.status !== 'PENDING'"
> @click="declineGroupJoin(row)"
拒绝 >
</el-button> 拒绝
</template> </el-button>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-dialog> </el-dialog>
@ -629,11 +645,13 @@
<el-descriptions-item label="失败处理">回调发送失败只记录日志不会中断消息发送撤回等主流程</el-descriptions-item> <el-descriptions-item label="失败处理">回调发送失败只记录日志不会中断消息发送撤回等主流程</el-descriptions-item>
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item> <el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-table :data="webhookEvents" border stripe style="margin-top:16px"> <div class="table-wrap" style="margin-top:16px">
<el-table-column prop="event" label="事件" width="180" /> <el-table :data="webhookEvents" border stripe>
<el-table-column prop="payload" label="payload" width="220" /> <el-table-column prop="event" label="事件" width="180" />
<el-table-column prop="description" label="说明" min-width="320" /> <el-table-column prop="payload" label="payload" width="220" />
</el-table> <el-table-column prop="description" label="说明" min-width="320" />
</el-table>
</div>
<template #footer> <template #footer>
<el-button type="primary" @click="showWebhookGuideDialog = false">关闭</el-button> <el-button type="primary" @click="showWebhookGuideDialog = false">关闭</el-button>
</template> </template>
@ -1722,6 +1740,18 @@ function userAvatarFallback(row: ImUser) {
margin-bottom: 12px; margin-bottom: 12px;
} }
.table-wrap {
overflow-x: auto;
}
.responsive-toolbar {
flex-wrap: wrap;
}
.stat-grid {
margin-bottom: 16px;
}
.toolbar-space-between { .toolbar-space-between {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1812,4 +1842,59 @@ function userAvatarFallback(row: ImUser) {
word-break: break-word; word-break: break-word;
line-height: 1.5; line-height: 1.5;
} }
@media (max-width: 767px) {
.stat-grid :deep(.el-col) {
margin-bottom: 12px;
}
.toolbar-space-between {
flex-direction: column;
align-items: stretch;
}
.toolbar-group {
width: 100%;
}
.toolbar-group :deep(.el-button),
.toolbar-group :deep(.el-select),
.toolbar-group :deep(.el-input),
.toolbar-group :deep(.el-switch) {
width: 100%;
}
.responsive-toolbar :deep(.el-button),
.responsive-toolbar :deep(.el-select),
.responsive-toolbar :deep(.el-input),
.responsive-toolbar :deep(.el-radio-group) {
width: 100%;
}
.responsive-toolbar :deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
}
.responsive-toolbar :deep(.el-radio-button) {
flex: 1 1 32%;
}
.table-wrap :deep(.el-table) {
min-width: 920px;
}
.section-head {
flex-direction: column;
align-items: stretch;
}
.section-head .toolbar-group {
justify-content: flex-start;
}
.search-results {
max-height: 240px;
}
}
</style> </style>

查看文件

@ -1,6 +1,6 @@
<template> <template>
<el-container class="layout-container"> <el-container class="layout-container">
<el-aside width="220px" class="sidebar"> <el-aside v-if="!isMobile" width="220px" class="sidebar">
<div class="logo"> <div class="logo">
<span>XuqmGroup</span> <span>XuqmGroup</span>
</div> </div>
@ -10,24 +10,50 @@
background-color="#1d2129" background-color="#1d2129"
text-color="#c9d1d9" text-color="#c9d1d9"
active-text-color="#409eff" active-text-color="#409eff"
class="nav-menu"
> >
<el-menu-item index="/dashboard"> <el-menu-item v-for="item in navItems" :key="item.path" :index="item.path">
<el-icon><Odometer /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
<span>控制台</span> <span>{{ item.label }}</span>
</el-menu-item>
<el-menu-item index="/apps">
<el-icon><Grid /></el-icon>
<span>我的应用</span>
</el-menu-item>
<el-menu-item index="/accounts" v-if="auth.user?.type === 'MAIN'">
<el-icon><User /></el-icon>
<span>子账号管理</span>
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-drawer
v-model="drawerVisible"
direction="ltr"
:with-header="false"
size="250px"
class="mobile-drawer"
>
<div class="drawer-brand">
<span>XuqmGroup</span>
</div>
<el-menu
:default-active="$route.path"
router
background-color="#1d2129"
text-color="#c9d1d9"
active-text-color="#409eff"
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>
</el-drawer>
<el-container class="content-shell">
<el-header class="header"> <el-header class="header">
<div v-if="isMobile" class="mobile-header-left">
<el-button text circle class="menu-button" @click="drawerVisible = true">
<el-icon><Menu /></el-icon>
</el-button>
<span class="mobile-title">XuqmGroup</span>
</div>
<div v-else class="header-spacer" />
<div class="header-right"> <div class="header-right">
<el-tooltip content="开发者文档" placement="bottom"> <el-tooltip content="开发者文档" placement="bottom">
<a href="/docs/" target="_blank" class="docs-link"> <a href="/docs/" target="_blank" class="docs-link">
@ -51,7 +77,7 @@
</div> </div>
</el-header> </el-header>
<el-main> <el-main class="main-content">
<router-view /> <router-view />
</el-main> </el-main>
</el-container> </el-container>
@ -59,12 +85,39 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Document } from '@element-plus/icons-vue' import { Document, Grid, Menu, Odometer, User } from '@element-plus/icons-vue'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() 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: '/operation-logs', label: '操作日志', icon: Document },
]
if (auth.user?.type === 'MAIN') {
items.push({ path: '/accounts', label: '子账号管理', icon: User })
}
return items
})
function updateViewport() {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) {
drawerVisible.value = false
}
}
function handleNavSelect() {
drawerVisible.value = false
}
function handleCommand(cmd: string) { function handleCommand(cmd: string) {
if (cmd === 'logout') { if (cmd === 'logout') {
@ -72,14 +125,34 @@ function handleCommand(cmd: string) {
router.push('/login') router.push('/login')
} }
} }
watch(
() => route.path,
() => {
drawerVisible.value = false
},
)
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script> </script>
<style scoped> <style scoped>
.layout-container { .layout-container {
height: 100vh; height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
} }
.sidebar { .sidebar {
background: #1d2129; background: #1d2129;
flex: 0 0 220px;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.08);
} }
.logo { .logo {
height: 60px; height: 60px;
@ -91,18 +164,47 @@ function handleCommand(cmd: string) {
font-weight: bold; font-weight: bold;
border-bottom: 1px solid #2d3142; border-bottom: 1px solid #2d3142;
} }
.nav-menu {
border-right: none;
}
.content-shell {
min-width: 0;
}
.header { .header {
background: #fff; background: #fff;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
padding: 0 16px;
gap: 16px;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
position: sticky;
top: 0;
z-index: 9;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.header-spacer {
flex: 1;
}
.mobile-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mobile-title {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.menu-button {
color: #111827;
}
.docs-link { .docs-link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -128,4 +230,48 @@ function handleCommand(cmd: string) {
font-size: 14px; font-size: 14px;
color: #333; color: #333;
} }
.main-content {
padding: 16px;
overflow: auto;
}
.drawer-brand {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #2d3142;
background: #1d2129;
}
.mobile-drawer :deep(.el-drawer__body) {
padding: 0;
background: #1d2129;
}
@media (max-width: 767px) {
.layout-container {
display: block;
height: auto;
min-height: 100vh;
}
.header {
padding: 0 12px;
}
.header-right {
gap: 10px;
}
.docs-link span,
.nickname {
display: none;
}
.main-content {
padding: 12px;
}
}
</style> </style>

查看文件

@ -0,0 +1,162 @@
<template>
<div>
<h2 style="margin-bottom: 24px">操作日志</h2>
<el-card shadow="never">
<div class="toolbar responsive-toolbar">
<el-select v-model="filters.moduleType" placeholder="模块" style="width: 180px" clearable @change="handleModuleChange">
<el-option label="全部模块" value="" />
<el-option label="应用管理" value="APP" />
<el-option label="子账号管理" value="SUB_ACCOUNT" />
<el-option label="服务管理" value="SERVICE" />
<el-option label="密钥验证" value="APP_SECRET" />
<el-option label="邮箱验证" value="EMAIL_VERIFY" />
</el-select>
<el-button :loading="loading" @click="loadLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="logs" v-loading="loading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="moduleType" label="模块" width="140">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ moduleLabel(row.moduleType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="action" label="动作" width="180" />
<el-table-column prop="resourceType" label="资源类型" width="140" />
<el-table-column prop="resourceId" label="资源ID" min-width="220" show-overflow-tooltip />
<el-table-column prop="operator" label="操作人" width="180" />
<el-table-column label="详情" min-width="280">
<template #default="{ row }">
<span class="detail">{{ formatDetail(row.detailJson) }}</span>
</template>
</el-table-column>
</el-table>
</div>
<el-pagination
style="margin-top: 16px"
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page + 1"
@current-change="handlePageChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { operationLogApi, type TenantOperationLog } from '@/api/operationLog'
const loading = ref(false)
const logs = ref<TenantOperationLog[]>([])
const total = ref(0)
const page = ref(0)
const pageSize = ref(20)
const filters = reactive({
moduleType: '',
})
onMounted(() => {
loadLogs()
})
async function loadLogs() {
loading.value = true
try {
const res = await operationLogApi.list({
moduleType: filters.moduleType || undefined,
page: page.value,
size: pageSize.value,
})
const data = res.data.data
logs.value = data.content ?? []
total.value = data.totalElements ?? 0
} finally {
loading.value = false
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage - 1
loadLogs()
}
function handleModuleChange() {
page.value = 0
loadLogs()
}
function moduleLabel(moduleType: string) {
return {
APP: '应用管理',
SUB_ACCOUNT: '子账号管理',
SERVICE: '服务管理',
APP_SECRET: '应用密钥',
EMAIL_VERIFY: '邮箱验证',
}[moduleType] ?? moduleType
}
function formatDetail(detailJson?: string) {
if (!detailJson) return '-'
try {
const parsed = JSON.parse(detailJson)
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 0)
} catch {
return detailJson
}
}
function formatTime(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN')
}
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.responsive-toolbar {
flex-wrap: wrap;
}
.table-wrap {
overflow-x: auto;
}
.detail {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 767px) {
.responsive-toolbar {
align-items: stretch;
}
.responsive-toolbar :deep(.el-select),
.responsive-toolbar :deep(.el-button) {
width: 100%;
}
.table-wrap :deep(.el-table) {
min-width: 900px;
}
}
</style>

查看文件

@ -0,0 +1,307 @@
<template>
<div v-if="app">
<el-page-header @back="$router.back()" content="推送服务配置" style="margin-bottom:24px" />
<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="AppKey">
<el-text class="mono">{{ app.appKey }}</el-text>
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
</el-descriptions-item>
<el-descriptions-item label="服务状态">
<el-tag :type="pushEnabled ? 'success' : 'info'">{{ pushEnabled ? '已开通' : '未开通' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="push-switch-row">
<el-switch :model-value="pushEnabled" @change="(val: boolean) => onTogglePushService(val)" />
<span class="hint">关闭后推送注册与推送发送都不会再向该应用开放</span>
</div>
</el-card>
<el-alert
title="这里维护各厂商推送凭据。推送服务开通后,离线推送才会按厂商路由发送。"
type="info"
:closable="false"
show-icon
style="margin-bottom:16px"
/>
<el-card>
<template #header>厂商配置</template>
<div class="vendor-grid">
<el-card v-for="vendor in vendorDefs" :key="vendor.key" shadow="never" class="vendor-card">
<template #header>{{ vendor.label }}</template>
<div class="vendor-hint">{{ vendor.hint }}</div>
<el-form :label-position="isMobile ? 'top' : 'right'" label-width="120px">
<el-form-item v-for="field in vendor.fields" :key="field.key" :label="field.label">
<el-input
v-if="field.type === 'textarea'"
v-model="pushConfig[vendor.key][field.key]"
type="textarea"
:rows="field.rows ?? 4"
:placeholder="field.placeholder"
/>
<el-switch
v-else-if="field.type === 'switch'"
v-model="pushConfig[vendor.key][field.key]"
/>
<el-input
v-else
v-model="pushConfig[vendor.key][field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:placeholder="field.placeholder"
/>
</el-form-item>
</el-form>
</el-card>
</div>
<div class="toolbar">
<el-button @click="reloadConfig" :loading="loading">刷新</el-button>
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { appApi, type App, type FeatureService, type PushServiceConfig } from '@/api/app'
type VendorKey = keyof PushServiceConfig
type FieldDef = {
key: string
label: string
type?: 'password' | 'textarea' | 'switch'
placeholder?: string
rows?: number
}
type VendorDef = {
key: VendorKey
label: string
hint: string
fields: FieldDef[]
}
const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const loading = ref(false)
const saving = ref(false)
const isMobile = ref(window.innerWidth < 768)
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
const pushConfig = reactive<Required<PushServiceConfig>>({
huawei: { appId: '', appSecret: '' },
xiaomi: { appId: '', appKey: '', appSecret: '' },
oppo: { appId: '', appKey: '', masterSecret: '' },
vivo: { appId: '', appKey: '', appSecret: '' },
honor: { appId: '', clientId: '', clientSecret: '' },
apns: { teamId: '', keyId: '', bundleId: '', keyPath: '', sandbox: false },
fcm: { serviceAccountJson: '' },
} as Required<PushServiceConfig>)
const vendorDefs: VendorDef[] = [
{
key: 'huawei',
label: '华为 HMS',
hint: '填写 AppId / AppSecret,供服务端推送使用。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
],
},
{
key: 'xiaomi',
label: '小米 MiPush',
hint: '填写 AppId / AppKey / AppSecret。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
],
},
{
key: 'oppo',
label: 'OPPO 推送',
hint: '填写 AppId / AppKey / MasterSecret。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'masterSecret', label: 'MasterSecret', type: 'password' },
],
},
{
key: 'vivo',
label: 'vivo 推送',
hint: '填写 AppId / AppKey / AppSecret。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'appKey', label: 'AppKey' },
{ key: 'appSecret', label: 'AppSecret', type: 'password' },
],
},
{
key: 'honor',
label: '荣耀推送',
hint: '填写 AppId / ClientId / ClientSecret。',
fields: [
{ key: 'appId', label: 'AppId' },
{ key: 'clientId', label: 'ClientId' },
{ key: 'clientSecret', label: 'ClientSecret', type: 'password' },
],
},
{
key: 'apns',
label: 'APNsiOS',
hint: '填写 Team ID / Key ID / Bundle ID / p8 文件路径。',
fields: [
{ key: 'teamId', label: 'Team ID' },
{ key: 'keyId', label: 'Key ID' },
{ key: 'bundleId', label: 'Bundle ID' },
{ key: 'keyPath', label: 'p8 文件路径' },
{ key: 'sandbox', label: 'Sandbox', type: 'switch' },
],
},
]
const pushEnabled = computed(() => services.value.some(s => s.serviceType === 'PUSH' && s.enabled))
const servicePlatform = computed(() => services.value.find(s => s.serviceType === 'PUSH')?.platform ?? 'ANDROID')
async function loadData() {
const id = route.params.appId as string
const [appRes, svcRes] = await Promise.all([
appApi.get(id),
appApi.getServices(id),
])
app.value = appRes.data.data
services.value = svcRes.data.data
applyConfig(services.value.find(s => s.serviceType === 'PUSH')?.config)
}
function applyConfig(raw?: string | null) {
const parsed = parseConfig(raw)
pushConfig.huawei = { ...pushConfig.huawei, ...parsed.huawei }
pushConfig.xiaomi = { ...pushConfig.xiaomi, ...parsed.xiaomi }
pushConfig.oppo = { ...pushConfig.oppo, ...parsed.oppo }
pushConfig.vivo = { ...pushConfig.vivo, ...parsed.vivo }
pushConfig.honor = { ...pushConfig.honor, ...parsed.honor }
pushConfig.apns = { ...pushConfig.apns, ...parsed.apns }
pushConfig.fcm = { ...pushConfig.fcm, ...parsed.fcm }
}
function parseConfig(raw?: string | null): PushServiceConfig {
if (!raw) return {}
try {
return JSON.parse(raw) as PushServiceConfig
} catch {
return {}
}
}
async function saveConfig() {
if (!app.value) return
saving.value = true
try {
await appApi.updateServiceConfig(app.value.id, servicePlatform.value, 'PUSH', pushConfig)
ElMessage.success('推送配置已保存')
await loadData()
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function onTogglePushService(enable: boolean) {
if (enable) {
ElMessage.info('请通过服务开通流程启用推送服务')
return
}
await ElMessageBox.confirm('确认关闭离线推送服务?', '关闭服务', {
type: 'warning',
confirmButtonText: '确认关闭',
cancelButtonText: '取消',
})
if (!app.value) return
await appApi.toggleService(app.value.id, servicePlatform.value, 'PUSH', false)
ElMessage.success('已关闭')
await loadData()
}
function reloadConfig() {
return loadData()
}
function copy(text: string) {
navigator.clipboard.writeText(text)
ElMessage.success('已复制')
}
onMounted(loadData)
onMounted(() => window.addEventListener('resize', updateViewport))
onBeforeUnmount(() => window.removeEventListener('resize', updateViewport))
</script>
<style scoped>
.mono { font-family: monospace; font-size: 12px; }
.hint { font-size: 12px; color: #909399; }
.info-card :deep(.el-descriptions__body) {
overflow-x: auto;
}
.push-switch-row {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.vendor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.vendor-card {
min-height: 100%;
}
.vendor-hint {
margin-bottom: 12px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.toolbar {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 767px) {
.vendor-grid {
grid-template-columns: 1fr;
}
.push-switch-row {
flex-direction: column;
align-items: flex-start;
}
.toolbar :deep(.el-button) {
width: 100%;
}
.vendor-card :deep(.el-form-item__label) {
padding-bottom: 4px;
}
}
</style>

查看文件

@ -125,8 +125,8 @@ const STORE_GUIDES = [
urlLabel: '查看鸿蒙官方文档', urlLabel: '查看鸿蒙官方文档',
steps: [ steps: [
{ title: '确认鸿蒙应用页', description: '准备独立的鸿蒙应用市场详情页。' }, { title: '确认鸿蒙应用页', description: '准备独立的鸿蒙应用市场详情页。' },
{ title: '复制跳转链接', description: '把市场详情页链接填写到应用商店配置。' }, { title: '复制跳转链接', description: '如需市场跳转,再把详情页链接填写到应用商店配置。' },
{ title: '回到版本管理页', description: '鸿蒙版本仅记录版本号和跳转页。' }, { title: '回到版本管理页', description: '鸿蒙版本仅记录版本号和跳转页,不填也可继续。' },
], ],
hint: '鸿蒙应用配置只维护市场跳转页,不参与 Android 审核上传。', hint: '鸿蒙应用配置只维护市场跳转页,不参与 Android 审核上传。',
enabled: true, enabled: true,
@ -139,10 +139,10 @@ const STORE_GUIDES = [
urlLabel: '查看 Apple 官方文档', urlLabel: '查看 Apple 官方文档',
steps: [ steps: [
{ title: '创建 API Key', description: '保存 Team ID / Key ID / p8 私钥。' }, { title: '创建 API Key', description: '保存 Team ID / Key ID / p8 私钥。' },
{ title: '补充 Bundle ID', description: '回到版本管理页保存包名与链接。' }, { title: '补充 Bundle ID', description: '回到版本管理页保存包名;链接可选。' },
{ title: '提交审核', description: '支持审核后自动发布。' }, { title: '提交审核', description: '支持审核后自动发布,链接不填也能继续走流程。' },
], ],
hint: 'iOS 可填写 App Store 链接。', hint: 'iOS 的 App Store 链接可选填写,需要跳转时再补。',
enabled: true, enabled: true,
}, },
{ {

查看文件

@ -6,7 +6,7 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<!-- App Versions --> <!-- App Versions -->
<el-tab-pane label="App 整包版本" name="app"> <el-tab-pane label="App 整包版本" name="app">
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-radio-group v-model="appPlatform" @change="loadAppVersions" style="margin-right:12px"> <el-radio-group v-model="appPlatform" @change="loadAppVersions" style="margin-right:12px">
<el-radio-button value="ANDROID">Android</el-radio-button> <el-radio-button value="ANDROID">Android</el-radio-button>
<el-radio-button value="IOS">iOS</el-radio-button> <el-radio-button value="IOS">iOS</el-radio-button>
@ -16,6 +16,7 @@
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button> <el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
</div> </div>
<div class="table-wrap">
<el-table :data="appVersions" v-loading="loadingApp" border stripe> <el-table :data="appVersions" v-loading="loadingApp" border stripe>
<el-table-column prop="versionName" label="版本名" width="110" /> <el-table-column prop="versionName" label="版本名" width="110" />
<el-table-column prop="versionCode" label="版本码" width="90" /> <el-table-column prop="versionCode" label="版本码" width="90" />
@ -33,13 +34,25 @@
<el-table-column label="应用商店" width="220" show-overflow-tooltip> <el-table-column label="应用商店" width="220" show-overflow-tooltip>
<template #default="{row}"> <template #default="{row}">
<template v-if="parseStoreReview(row.storeReviewStatus).length"> <template v-if="parseStoreReview(row.storeReviewStatus).length">
<el-tag <template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
v-for="item in parseStoreReview(row.storeReviewStatus)" <el-tooltip
:key="item.store" v-if="item.state === 'REJECTED' && item.reason"
:type="reviewTagType(item.state)" :content="item.reason"
size="small" placement="top"
style="margin:2px" >
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag> <el-tag
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</el-tooltip>
<el-tag
v-else
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
</template>
</template> </template>
<span v-else class="text-muted"></span> <span v-else class="text-muted"></span>
</template> </template>
@ -69,10 +82,14 @@
v-if="row.publishStatus === 'PUBLISHED'" v-if="row.publishStatus === 'PUBLISHED'"
link type="warning" size="small" link type="warning" size="small"
@click="openGrayDialog(row, 'app')">灰度</el-button> @click="openGrayDialog(row, 'app')">灰度</el-button>
<el-button
v-if="row.publishStatus === 'PUBLISHED'"
link type="primary" size="small"
@click="openPublishDialog(row, 'app')">修改强更</el-button>
<el-button <el-button
v-if="row.publishStatus === 'PUBLISHED'" v-if="row.publishStatus === 'PUBLISHED'"
link type="danger" size="small" link type="danger" size="small"
@click="unpublishApp(row.id)">下架</el-button> @click="promptUnpublishApp(row.id)">下架</el-button>
<el-button <el-button
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'" v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
link type="primary" size="small" link type="primary" size="small"
@ -80,11 +97,12 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
<!-- RN Bundles --> <!-- RN Bundles -->
<el-tab-pane label="RN Bundle 热更新" name="rn"> <el-tab-pane label="RN Bundle 热更新" name="rn">
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-input <el-input
v-model="rnModuleFilter" v-model="rnModuleFilter"
placeholder="模块ID可选" placeholder="模块ID可选"
@ -101,6 +119,7 @@
<el-button @click="loadRnBundles" :loading="loadingRn">刷新</el-button> <el-button @click="loadRnBundles" :loading="loadingRn">刷新</el-button>
</div> </div>
<div class="table-wrap">
<el-table :data="rnBundles" v-loading="loadingRn" border stripe> <el-table :data="rnBundles" v-loading="loadingRn" border stripe>
<el-table-column prop="moduleId" label="模块ID" width="140" /> <el-table-column prop="moduleId" label="模块ID" width="140" />
<el-table-column prop="version" label="版本" width="100" /> <el-table-column prop="version" label="版本" width="100" />
@ -126,10 +145,11 @@
<el-button v-if="row.publishStatus === 'DRAFT'" link type="success" size="small" @click="openPublishDialog(row, 'rn')">发布</el-button> <el-button v-if="row.publishStatus === 'DRAFT'" link type="success" size="small" @click="openPublishDialog(row, 'rn')">发布</el-button>
<el-button v-if="row.publishStatus === 'DEPRECATED'" link type="warning" size="small" @click="openPublishDialog(row, 'rn')">重新上架</el-button> <el-button v-if="row.publishStatus === 'DEPRECATED'" link type="warning" size="small" @click="openPublishDialog(row, 'rn')">重新上架</el-button>
<el-button v-if="row.publishStatus === 'PUBLISHED'" link type="warning" size="small" @click="openGrayDialog(row, 'rn')">灰度</el-button> <el-button v-if="row.publishStatus === 'PUBLISHED'" link type="warning" size="small" @click="openGrayDialog(row, 'rn')">灰度</el-button>
<el-button v-if="row.publishStatus === 'PUBLISHED'" link type="danger" size="small" @click="unpublishRn(row.id)">下架</el-button> <el-button v-if="row.publishStatus === 'PUBLISHED'" link type="danger" size="small" @click="promptUnpublishRn(row.id)">下架</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
</el-tab-pane> </el-tab-pane>
<!-- App Store Config --> <!-- App Store Config -->
@ -226,7 +246,7 @@
:closable="false" :closable="false"
style="margin-bottom:16px" style="margin-bottom:16px"
/> />
<div class="toolbar"> <div class="toolbar responsive-toolbar">
<el-button @click="loadPublishConfig" :loading="loadingPublishConfig">刷新</el-button> <el-button @click="loadPublishConfig" :loading="loadingPublishConfig">刷新</el-button>
<el-button type="primary" @click="savePublishConfig" :loading="savingPublishConfig">保存配置</el-button> <el-button type="primary" @click="savePublishConfig" :loading="savingPublishConfig">保存配置</el-button>
</div> </div>
@ -235,7 +255,7 @@
<el-form-item label="默认灰度模式"> <el-form-item label="默认灰度模式">
<el-radio-group v-model="publishConfigForm.grayMode"> <el-radio-group v-model="publishConfigForm.grayMode">
<el-radio-button value="PERCENT">比例</el-radio-button> <el-radio-button value="PERCENT">比例</el-radio-button>
<el-radio-button value="MEMBERS" :disabled="!hasAnyGrayCallback">成员</el-radio-button> <el-radio-button value="MEMBERS">成员</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="publishConfigForm.grayMode === 'PERCENT'" label="默认灰度比例"> <el-form-item v-if="publishConfigForm.grayMode === 'PERCENT'" label="默认灰度比例">
@ -245,9 +265,15 @@
<el-form-item label="成员选择回调"> <el-form-item label="成员选择回调">
<el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" /> <el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" />
</el-form-item> </el-form-item>
<el-form-item label="成员选择密钥">
<el-input v-model="publishConfigForm.graySelectCallbackSecret" type="password" show-password placeholder="可选,用于成员选择回调验签" />
</el-form-item>
<el-form-item label="成员目录同步回调"> <el-form-item label="成员目录同步回调">
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" /> <el-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" />
</el-form-item> </el-form-item>
<el-form-item label="成员目录密钥">
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackSecret" type="password" show-password placeholder="可选,用于成员同步回调验签" />
</el-form-item>
<el-form-item label="成员选择方式"> <el-form-item label="成员选择方式">
<el-radio-group v-model="publishConfigForm.graySelectionSource"> <el-radio-group v-model="publishConfigForm.graySelectionSource">
<el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button> <el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button>
@ -260,11 +286,36 @@
</template> </template>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
<!-- Operation Logs -->
<el-tab-pane label="操作记录" name="logs">
<div class="toolbar responsive-toolbar">
<el-button @click="loadOperationLogs" :loading="loadingOperationLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="operationLogs" v-loading="loadingOperationLogs" border stripe>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="resourceType" label="对象" width="120">
<template #default="{row}">{{ operationResourceLabel(row.resourceType) }}</template>
</el-table-column>
<el-table-column prop="action" label="操作" width="140">
<template #default="{row}">{{ operationActionLabel(row.action) }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="140" />
<el-table-column prop="reason" label="原因" width="220" show-overflow-tooltip />
<el-table-column prop="detailJson" label="详情" show-overflow-tooltip>
<template #default="{row}">{{ formatDetail(row.detailJson) }}</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
<!-- Publish Dialog --> <!-- Publish Dialog -->
<el-dialog v-model="showPublish" title="发布 / 重新上架" width="520px"> <el-dialog v-model="showPublish" title="发布 / 重新上架" :width="dialogWidth">
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="发布方式"> <el-form-item label="发布方式">
<el-radio-group v-model="publishForm.publishImmediately"> <el-radio-group v-model="publishForm.publishImmediately">
@ -293,7 +344,7 @@
</el-dialog> </el-dialog>
<!-- Gray Release Dialog --> <!-- Gray Release Dialog -->
<el-dialog v-model="showGray" title="灰度发布配置" width="920px"> <el-dialog v-model="showGray" title="灰度发布配置" :width="dialogWidth">
<el-form label-width="110px"> <el-form label-width="110px">
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item> <el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
<el-form-item label="灰度方式" v-if="grayForm.enabled"> <el-form-item label="灰度方式" v-if="grayForm.enabled">
@ -359,7 +410,7 @@
</el-dialog> </el-dialog>
<!-- Submit to Store Dialog --> <!-- Submit to Store Dialog -->
<el-dialog v-model="showSubmitStore" title="提交应用市场" width="620px"> <el-dialog v-model="showSubmitStore" title="提交应用市场" :width="dialogWidth">
<div v-if="submitStoreVersion"> <div v-if="submitStoreVersion">
<p style="margin-bottom:12px"> <p style="margin-bottom:12px">
版本 <strong>{{ submitStoreVersion.versionName }}</strong> 版本 <strong>{{ submitStoreVersion.versionName }}</strong>
@ -414,7 +465,7 @@
<el-dialog <el-dialog
v-model="showStoreConfig" v-model="showStoreConfig"
:title="`配置 ${currentStoreDef?.label} 凭据`" :title="`配置 ${currentStoreDef?.label} 凭据`"
width="720px" :width="dialogWidth"
> >
<el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="150px" class="store-config-form"> <el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="150px" class="store-config-form">
<el-form-item label="启用"> <el-form-item label="启用">
@ -451,10 +502,10 @@
</el-dialog> </el-dialog>
<!-- Upload App Version Dialog --> <!-- Upload App Version Dialog -->
<el-dialog v-model="showUploadApp" title="上传 App 版本" width="680px"> <el-dialog v-model="showUploadApp" title="上传 App 版本" :width="dialogWidth">
<el-form :model="appUploadForm" label-width="120px"> <el-form :model="appUploadForm" label-width="120px">
<el-form-item label="平台"> <el-form-item label="平台">
<el-select v-model="appUploadForm.platform" @change="appUploadForm.packageName = app?.packageName ?? appUploadForm.packageName"> <el-select v-model="appUploadForm.platform" @change="handleAppPlatformChange">
<el-option value="ANDROID" label="Android" /> <el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" /> <el-option value="IOS" label="iOS" />
<el-option value="HARMONY" label="Harmony" /> <el-option value="HARMONY" label="Harmony" />
@ -466,6 +517,12 @@
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" placeholder="选择文件后可自动填充" /></el-form-item> <el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" placeholder="选择文件后可自动填充" /></el-form-item>
<el-form-item label="版本码"><el-input-number v-model="appUploadForm.versionCode" :min="1" /></el-form-item> <el-form-item label="版本码"><el-input-number v-model="appUploadForm.versionCode" :min="1" /></el-form-item>
<el-form-item label="更新说明"><el-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item> <el-form-item label="更新说明"><el-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item>
<el-form-item v-if="appUploadForm.platform === 'IOS'" label="App Store 链接(可选)">
<el-input v-model="appUploadForm.appStoreUrl" placeholder="可自动从应用商店配置回显" />
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'HARMONY'" label="应用市场链接(可选)">
<el-input v-model="appUploadForm.marketUrl" placeholder="鸿蒙应用市场详情页链接" />
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'ANDROID'" label="包文件"> <el-form-item v-if="appUploadForm.platform === 'ANDROID'" label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk"> <el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk">
<el-button :loading="appPackageInspecting" :disabled="appPackageInspecting"> <el-button :loading="appPackageInspecting" :disabled="appPackageInspecting">
@ -475,17 +532,17 @@
</el-form-item> </el-form-item>
<el-alert <el-alert
v-if="appUploadForm.platform !== 'ANDROID'" v-if="appUploadForm.platform !== 'ANDROID'"
type="warning" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="iOS 和鸿蒙只记录版本信息,不需要上传安装包。包名会自动回显当前应用包名。" title="iOS 和鸿蒙只记录版本信息,不需要上传安装包。商店跳转链接可选填写,方便后续跳转与回显。"
/> />
<el-alert <el-alert
v-if="appUploadForm.platform === 'ANDROID'" v-if="appUploadForm.platform === 'ANDROID'"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,会直接提示。" title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,可选择强制继续使用。"
/> />
</el-form> </el-form>
<template #footer> <template #footer>
@ -495,7 +552,7 @@
</el-dialog> </el-dialog>
<!-- Upload RN Bundle Dialog --> <!-- Upload RN Bundle Dialog -->
<el-dialog v-model="showUploadRn" title="上传 RN Bundle" width="760px"> <el-dialog v-model="showUploadRn" title="上传 RN Bundle" :width="dialogWidth">
<el-form :model="rnUploadForm" label-width="120px"> <el-form :model="rnUploadForm" label-width="120px">
<el-form-item label="Bundle 文件"> <el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onRnBundleChange" accept=".bundle,.js"> <el-upload :auto-upload="false" :limit="1" :on-change="onRnBundleChange" accept=".bundle,.js">
@ -533,7 +590,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type App } from '@/api/app' import { appApi, type App } from '@/api/app'
@ -561,6 +618,8 @@ const route = useRoute()
const appId = route.params.appId as string const appId = route.params.appId as string
const app = ref<App | null>(null) const app = ref<App | null>(null)
const pageTitle = computed(() => app.value?.name ?? appId) const pageTitle = computed(() => app.value?.name ?? appId)
const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '920px'))
const activeTab = ref('app') const activeTab = ref('app')
const storeTab = ref<'configs' | 'guide'>('configs') const storeTab = ref<'configs' | 'guide'>('configs')
@ -580,7 +639,9 @@ const publishConfigForm = ref({
grayMode: 'PERCENT' as GrayMode, grayMode: 'PERCENT' as GrayMode,
graySelectionSource: 'LOCAL' as GraySelectionSource, graySelectionSource: 'LOCAL' as GraySelectionSource,
graySelectCallbackUrl: '', graySelectCallbackUrl: '',
graySelectCallbackSecret: '',
grayDirectorySyncCallbackUrl: '', grayDirectorySyncCallbackUrl: '',
grayDirectorySyncCallbackSecret: '',
}) })
const grayMembers = ref<GrayMemberGroup[]>([]) const grayMembers = ref<GrayMemberGroup[]>([])
const loadingGrayMembers = ref(false) const loadingGrayMembers = ref(false)
@ -588,6 +649,18 @@ const grayMemberKeyword = ref('')
const grayMemberGroupFilter = ref('') const grayMemberGroupFilter = ref('')
const grayMemberIds = ref<string[]>([]) const grayMemberIds = ref<string[]>([])
const appPackageInspecting = ref(false) const appPackageInspecting = ref(false)
const operationLogs = ref<{
id: string
appId: string
resourceType: string
resourceId: string
action: string
operator?: string
reason?: string
detailJson?: string
createdAt: string
}[]>([])
const loadingOperationLogs = ref(false)
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim())) const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim())) const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
@ -596,7 +669,7 @@ const hasAnyGrayCallback = computed(() => hasGraySelectCallback.value || hasGray
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string } type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
type GuideStep = { title: string; description: string } type GuideStep = { title: string; description: string }
function marketUrlField(placeholder: string): FieldDef { function marketUrlField(placeholder: string): FieldDef {
return { key: 'marketUrl', label: '应用市场跳转页面', placeholder } return { key: 'marketUrl', label: '应用市场跳转页面(可选)', placeholder }
} }
type StoreDef = { type StoreDef = {
type: StoreType type: StoreType
@ -710,7 +783,7 @@ const STORE_DEFS: StoreDef[] = [
{ title: '保存 Client_id / 密钥', description: '与这里的 Client ID / Client Secret 对应。' }, { title: '保存 Client_id / 密钥', description: '与这里的 Client ID / Client Secret 对应。' },
], ],
jumpLinkHint: '荣耀应用的详情页链接请填写应用在荣耀市场的公开详情页地址。', jumpLinkHint: '荣耀应用的详情页链接请填写应用在荣耀市场的公开详情页地址。',
guideHint: '与后端 Honor 提交流程完全一致。', guideHint: '与后端 Honor 提交流程完全一致;跳转页可选填写。',
guideImage: honorGuideImage, guideImage: honorGuideImage,
}, },
{ {
@ -727,8 +800,8 @@ const STORE_DEFS: StoreDef[] = [
{ title: '复制 App Store 页面链接', description: '这里只需要保存跳转页,不需要再填密钥。' }, { title: '复制 App Store 页面链接', description: '这里只需要保存跳转页,不需要再填密钥。' },
{ title: '保存配置', description: '配置完成后可在版本提醒中直接跳转。' }, { title: '保存配置', description: '配置完成后可在版本提醒中直接跳转。' },
], ],
jumpLinkHint: 'App Store 链接直接填写公开详情页 URL,通常以 apps.apple.com 开头。', jumpLinkHint: 'App Store 链接可选填写,通常以 apps.apple.com 开头,需要跳转时再补。',
guideHint: '不再配置 Team ID、Key ID 或私钥,发布侧只使用跳转链接。', guideHint: '不再配置 Team ID、Key ID 或私钥;发布侧只在需要跳转时使用该链接。',
}, },
{ {
type: 'GOOGLE_PLAY', type: 'GOOGLE_PLAY',
@ -762,7 +835,7 @@ const STORE_DEFS: StoreDef[] = [
{ title: '复制详情页链接', description: '填写市场跳转页面,便于版本提醒直接跳转。' }, { title: '复制详情页链接', description: '填写市场跳转页面,便于版本提醒直接跳转。' },
{ title: '保存配置', description: '配置完成后可在版本提醒里引用。' }, { title: '保存配置', description: '配置完成后可在版本提醒里引用。' },
], ],
jumpLinkHint: '鸿蒙应用市场通常使用 appgallery.huawei.com/app/detail?id=包名 的公开详情页链接。', jumpLinkHint: '鸿蒙应用市场详情页链接可选填写,通常使用 appgallery.huawei.com/app/detail?id=包名。',
guideHint: '这里只保存鸿蒙应用市场的独立跳转页,不参与 Android 审核提交。', guideHint: '这里只保存鸿蒙应用市场的独立跳转页,不参与 Android 审核提交。',
}, },
{ {
@ -857,12 +930,6 @@ async function saveStoreConfig() {
ElMessage.warning('请填写审核通知地址') ElMessage.warning('请填写审核通知地址')
return return
} }
} else {
const marketUrl = storeConfigForm.value.values.marketUrl?.trim() ?? ''
if (!marketUrl) {
ElMessage.warning('请填写应用市场跳转页面')
return
}
} }
savingStoreConfig.value = true savingStoreConfig.value = true
try { try {
@ -901,7 +968,9 @@ function normalizePublishConfig(raw: Record<string, unknown> | null | undefined)
grayMode, grayMode,
graySelectionSource, graySelectionSource,
graySelectCallbackUrl: String((raw as Record<string, unknown>)?.graySelectCallbackUrl ?? ''), graySelectCallbackUrl: String((raw as Record<string, unknown>)?.graySelectCallbackUrl ?? ''),
graySelectCallbackSecret: String((raw as Record<string, unknown>)?.graySelectCallbackSecret ?? ''),
grayDirectorySyncCallbackUrl: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl ?? ''), grayDirectorySyncCallbackUrl: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl ?? ''),
grayDirectorySyncCallbackSecret: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackSecret ?? ''),
} }
} }
@ -990,6 +1059,7 @@ async function confirmSubmitToStores() {
ElMessage.success('已提交,服务端正在向应用市场上传,审核状态将通过 Webhook 或刷新页面查看') ElMessage.success('已提交,服务端正在向应用市场上传,审核状态将通过 Webhook 或刷新页面查看')
showSubmitStore.value = false showSubmitStore.value = false
await loadAppVersions() await loadAppVersions()
await loadOperationLogs()
} catch { } catch {
ElMessage.error('提交失败') ElMessage.error('提交失败')
} finally { } finally {
@ -1097,6 +1167,7 @@ async function submitGray() {
await updateAdminApi.grayRnBundle(id, payload) await updateAdminApi.grayRnBundle(id, payload)
await loadRnBundles() await loadRnBundles()
} }
await loadOperationLogs()
ElMessage.success('灰度配置已保存') ElMessage.success('灰度配置已保存')
showGray.value = false showGray.value = false
} finally { } finally {
@ -1112,10 +1183,40 @@ const appUploadForm = ref({
versionName: '', versionName: '',
versionCode: 1, versionCode: 1,
changeLog: '', changeLog: '',
appStoreUrl: '',
marketUrl: '',
file: null as File | null, file: null as File | null,
fileUrl: '', fileUrl: '',
}) })
function getStoreJumpUrl(storeType: StoreType) {
const cfg = storeConfigs.value.find(c => c.storeType === storeType)
if (!cfg?.configJson) return ''
try {
const parsed = JSON.parse(cfg.configJson) as Record<string, unknown>
const jump = String(parsed.marketUrl ?? '').trim()
return jump
} catch {
return ''
}
}
function handleAppPlatformChange() {
if (appUploadForm.value.platform === 'ANDROID') {
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
return
}
if (appUploadForm.value.platform === 'IOS') {
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
appUploadForm.value.appStoreUrl = getStoreJumpUrl('APP_STORE') || appUploadForm.value.appStoreUrl
return
}
if (appUploadForm.value.platform === 'HARMONY') {
appUploadForm.value.packageName = app.value?.packageName ?? appUploadForm.value.packageName
appUploadForm.value.marketUrl = getStoreJumpUrl('HARMONY_APP') || appUploadForm.value.marketUrl
}
}
async function onAppPackageChange(uploadFile: { raw?: File } | null) { async function onAppPackageChange(uploadFile: { raw?: File } | null) {
const file = uploadFile?.raw ?? null const file = uploadFile?.raw ?? null
appUploadForm.value.file = file appUploadForm.value.file = file
@ -1131,14 +1232,22 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
const inspected = res.data.data as AppPackageInspectResult const inspected = res.data.data as AppPackageInspectResult
const currentPackageName = app.value?.packageName?.trim() ?? '' const currentPackageName = app.value?.packageName?.trim() ?? ''
if (currentPackageName && inspected.packageName && inspected.packageName !== currentPackageName) { if (currentPackageName && inspected.packageName && inspected.packageName !== currentPackageName) {
await ElMessageBox.alert( try {
`当前应用包名是 ${currentPackageName},你选择的文件包名是 ${inspected.packageName}。请重新选择匹配的安装包。`, await ElMessageBox.confirm(
'包名不一致', `当前应用包名是 ${currentPackageName},你选择的文件包名是 ${inspected.packageName}。是否强制继续使用这个包?`,
{ type: 'warning', confirmButtonText: '知道了' }, '包名不一致',
) {
appUploadForm.value.file = null type: 'warning',
appUploadForm.value.fileUrl = '' confirmButtonText: '强制使用',
return cancelButtonText: '重新选择',
},
)
appUploadForm.value.packageName = inspected.packageName
} catch {
appUploadForm.value.file = null
appUploadForm.value.fileUrl = ''
return
}
} }
if (inspected.platform) appUploadForm.value.platform = inspected.platform if (inspected.platform) appUploadForm.value.platform = inspected.platform
if (inspected.packageName) appUploadForm.value.packageName = inspected.packageName if (inspected.packageName) appUploadForm.value.packageName = inspected.packageName
@ -1165,6 +1274,8 @@ async function submitAppUpload() {
fd.append('versionCode', String(f.versionCode)) fd.append('versionCode', String(f.versionCode))
if (f.packageName) fd.append('packageName', f.packageName) if (f.packageName) fd.append('packageName', f.packageName)
if (f.changeLog) fd.append('changeLog', f.changeLog) if (f.changeLog) fd.append('changeLog', f.changeLog)
if (f.appStoreUrl) fd.append('appStoreUrl', f.appStoreUrl)
if (f.marketUrl) fd.append('marketUrl', f.marketUrl)
if (f.platform === 'ANDROID' && f.fileUrl) fd.append('apkUrl', f.fileUrl) if (f.platform === 'ANDROID' && f.fileUrl) fd.append('apkUrl', f.fileUrl)
await updateAdminApi.uploadAppVersion(fd) await updateAdminApi.uploadAppVersion(fd)
ElMessage.success('上传成功') ElMessage.success('上传成功')
@ -1320,6 +1431,7 @@ async function submitPublish() {
await updateAdminApi.publishRnBundle(publishTarget.value.id, body) await updateAdminApi.publishRnBundle(publishTarget.value.id, body)
await loadRnBundles() await loadRnBundles()
} }
await loadOperationLogs()
ElMessage.success('已保存发布操作') ElMessage.success('已保存发布操作')
showPublish.value = false showPublish.value = false
} catch { } catch {
@ -1329,16 +1441,56 @@ async function submitPublish() {
} }
} }
async function unpublishApp(id: string) { async function promptUnpublishReason(title: string) {
await updateAdminApi.unpublishAppVersion(id) let value = ''
ElMessage.success('已下架') try {
await loadAppVersions() await ElMessageBox.confirm('下架后该版本将不再作为可发布版本,请确认继续。', title, {
confirmButtonText: '下一步',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return null
}
try {
({ value } = await ElMessageBox.prompt('请输入下架原因', title, {
confirmButtonText: '下架',
cancelButtonText: '取消',
inputPlaceholder: '请填写下架原因',
inputValidator: (input: string) => Boolean(input && input.trim()),
inputErrorMessage: '请填写下架原因',
type: 'warning',
}))
} catch {
return null
}
return value
} }
async function unpublishRn(id: string) { async function promptUnpublishApp(id: string) {
await updateAdminApi.unpublishRnBundle(id) const reason = await promptUnpublishReason('下架确认')
ElMessage.success('已下架') if (!reason) return
await loadRnBundles() try {
await updateAdminApi.unpublishAppVersion(id, reason)
ElMessage.success('已下架')
await loadAppVersions()
await loadOperationLogs()
} catch {
ElMessage.error('下架失败')
}
}
async function promptUnpublishRn(id: string) {
const reason = await promptUnpublishReason('下架确认')
if (!reason) return
try {
await updateAdminApi.unpublishRnBundle(id, reason)
ElMessage.success('已下架')
await loadRnBundles()
await loadOperationLogs()
} catch {
ElMessage.error('下架失败')
}
} }
function formatTime(t: string) { function formatTime(t: string) {
@ -1365,11 +1517,78 @@ function reviewTagType(state: string): string {
return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? '' return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
} }
function parseStoreReview(json?: string): { store: string; state: string }[] { function operationResourceLabel(resourceType: string) {
return {
APP_VERSION: 'App 版本',
RN_BUNDLE: 'RN Bundle',
}[resourceType] ?? resourceType
}
function operationActionLabel(action: string) {
return {
UPLOAD: '上传',
PUBLISH: '发布',
REPUBLISH: '重新上架',
SCHEDULE_PUBLISH: '定时发布',
UPDATE_FORCE: '修改强更',
SAVE_DRAFT: '保存草稿',
UNPUBLISH: '下架',
STORE_SUBMIT: '提交市场',
STORE_REVIEW: '审核回写',
GRAY_UPDATE: '灰度配置',
AUTO_PUBLISH: '自动发布',
CREATE_STORE_CONFIG: '创建商店配置',
UPDATE_STORE_CONFIG: '更新商店配置',
DELETE_STORE_CONFIG: '删除商店配置',
}[action] ?? action
}
function formatDetail(detailJson?: string) {
if (!detailJson) return '-'
try {
const value = JSON.parse(detailJson) as Record<string, unknown>
return Object.entries(value)
.map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : String(val)}`)
.join(';')
} catch {
return detailJson
}
}
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
async function loadOperationLogs() {
loadingOperationLogs.value = true
try {
const res = await updateAdminApi.listOperationLogs(appId, 100)
operationLogs.value = res.data.data
} catch {
operationLogs.value = []
} finally {
loadingOperationLogs.value = false
}
}
function parseStoreReview(json?: string): { store: string; state: string; reason?: string }[] {
if (!json) return [] if (!json) return []
try { try {
const m = JSON.parse(json) as Record<string, string> const m = JSON.parse(json) as Record<string, unknown>
return Object.entries(m).map(([store, state]) => ({ store, state })) return Object.entries(m).map(([store, value]) => {
if (typeof value === 'string') {
return { store, state: value, reason: '' }
}
if (value && typeof value === 'object') {
const item = value as Record<string, unknown>
return {
store,
state: String(item.state ?? ''),
reason: String(item.reason ?? ''),
}
}
return { store, state: String(value ?? ''), reason: '' }
})
} catch { } catch {
return [] return []
} }
@ -1381,6 +1600,15 @@ watch(app, (value) => {
} }
}) })
watch(storeConfigs, () => {
if (appUploadForm.value.platform === 'IOS') {
appUploadForm.value.appStoreUrl = getStoreJumpUrl('APP_STORE') || appUploadForm.value.appStoreUrl
}
if (appUploadForm.value.platform === 'HARMONY') {
appUploadForm.value.marketUrl = getStoreJumpUrl('HARMONY_APP') || appUploadForm.value.marketUrl
}
}, { deep: true })
watch([grayMemberKeyword, grayMemberGroupFilter], () => { watch([grayMemberKeyword, grayMemberGroupFilter], () => {
if (showGray.value && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL') { if (showGray.value && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL') {
loadGrayMembers() loadGrayMembers()
@ -1394,16 +1622,35 @@ watch(grayForm, () => {
}, { deep: true }) }, { deep: true })
onMounted(() => { onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
loadApp() loadApp()
loadAppVersions() loadAppVersions()
loadRnBundles() loadRnBundles()
loadStoreConfigs() loadStoreConfigs()
loadPublishConfig() loadPublishConfig()
loadOperationLogs()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
}) })
</script> </script>
<style scoped> <style scoped>
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } .toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.responsive-toolbar {
justify-content: space-between;
}
.table-wrap {
overflow-x: auto;
}
.text-muted { color: var(--el-text-color-placeholder); font-size: 12px; } .text-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-left: 8px; } .form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-left: 8px; }
@ -1519,4 +1766,50 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
} }
@media (max-width: 767px) {
.responsive-toolbar {
align-items: stretch;
}
.responsive-toolbar :deep(.el-radio-group),
.responsive-toolbar :deep(.el-button) {
width: 100%;
}
.responsive-toolbar :deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
}
.responsive-toolbar :deep(.el-radio-button) {
flex: 1 1 32%;
}
.table-wrap :deep(.el-table) {
min-width: 980px;
}
.store-grid,
.guide-grid {
grid-template-columns: 1fr;
}
.release-config-form :deep(.el-form-item__content) {
min-width: 0;
}
.release-store-checkbox-row {
flex-direction: column;
align-items: flex-start;
}
.release-store-checkbox-row :deep(.el-tag) {
margin-left: 0 !important;
}
:deep(.el-dialog) {
margin: 8px auto;
}
}
</style> </style>