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']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']

查看文件

@ -1,6 +1,6 @@
<template>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5">
<el-card style="width:360px">
<div class="login-page">
<el-card class="login-card">
<h2 style="text-align:center;margin-bottom:24px">运营平台登录</h2>
<el-form :model="form" @submit.prevent="login">
<el-form-item>
@ -38,3 +38,34 @@ async function login() {
}
}
</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>
<el-container style="height:100vh">
<el-aside width="200px" style="background:#1d2129">
<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">
<el-container class="layout-container">
<el-aside v-if="!isMobile" class="sidebar" width="200px">
<div class="logo">
XuqmGroup 运营平台
</div>
<el-menu router :default-active="$route.path" background-color="#1d2129" text-color="#c9d1d9" active-text-color="#409eff">
<el-menu-item index="/tenants"><el-icon><Avatar /></el-icon></el-menu-item>
<el-menu-item index="/statistics"><el-icon><TrendCharts /></el-icon></el-menu-item>
<el-menu-item index="/service-requests"><el-icon><Bell /></el-icon></el-menu-item>
<el-menu
router
:default-active="$route.path"
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-aside>
<el-container>
<el-header style="background:#fff;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:flex-end">
<el-button link @click="logout">退出登录</el-button>
<el-drawer
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-main><router-view /></el-main>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Avatar, Bell, Menu, TrendCharts } from '@element-plus/icons-vue'
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() {
localStorage.removeItem('ops_token')
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>
<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']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']

查看文件

@ -56,6 +56,31 @@ export interface UpdateServiceConfig {
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 = {
list: () => client.get<{ data: App[] }>('/apps'),
@ -84,7 +109,7 @@ export const appApi = {
appId: string,
platform: string,
serviceType: string,
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig>,
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig> & Partial<PushServiceConfig>,
) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
params: { platform, serviceType },

查看文件

@ -1,6 +1,7 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { isJwtExpired } from '@/utils/jwt'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
@ -27,22 +28,41 @@ if (import.meta.env.DEV) {
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
if (token && !isJwtExpired(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
})
function handleAuthFailure(message: string) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
ElMessage.error(message)
}
client.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
} else {
const msg = error.response?.data?.message ?? '请求失败'
ElMessage.error(msg)
const status = error.response?.status
if (status === 401) {
handleAuthFailure('登录已失效,请重新登录')
return Promise.reject(error)
}
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)
},
)

查看文件

@ -1,7 +1,7 @@
import axios from 'axios'
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,
})

查看文件

@ -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 { isJwtExpired } from '@/utils/jwt'
const updateClient = axios.create({
baseURL: 'http://192.168.116.9:8084',
baseURL: import.meta.env.VITE_UPDATE_API_BASE_URL ?? '',
timeout: 30000,
})
@ -34,11 +35,37 @@ updateClient.interceptors.request.use((config) => {
)
if (!skipAuth) {
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
})
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 StoreReviewState = 'PENDING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
@ -52,6 +79,18 @@ export interface PublishConfig {
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 {
userId: string
name?: string
@ -179,8 +218,8 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/updates/app/${id}/publish`, body ?? {})
},
unpublishAppVersion(id: string) {
return updateClient.post(`/api/v1/updates/app/${id}/unpublish`)
unpublishAppVersion(id: string, reason: string) {
return updateClient.post(`/api/v1/updates/app/${id}/unpublish`, { reason })
},
grayAppVersion(id: string, body: {
@ -213,8 +252,8 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/rn/${id}/publish`, body ?? {})
},
unpublishRnBundle(id: string) {
return updateClient.post(`/api/v1/rn/${id}/unpublish`)
unpublishRnBundle(id: string, reason: string) {
return updateClient.post(`/api/v1/rn/${id}/unpublish`, { reason })
},
grayRnBundle(id: string, body: {
@ -285,6 +324,12 @@ export const updateAdminApi = {
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) {
return updateClient.get<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members', {
params: { appId, ...(keyword && { keyword }), ...(groupName && { groupName }) },

查看文件

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

查看文件

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { decodeJwtPayload, isJwtExpired } from '@/utils/jwt'
export interface UserInfo {
id: string
@ -20,15 +21,20 @@ export const useAuthStore = defineStore('auth', () => {
function parseUser(t: string) {
try {
const payload = JSON.parse(atob(t.split('.')[1]))
const payload = decodeJwtPayload(t)
if (!payload || isJwtExpired(t)) {
throw new Error('token expired')
}
user.value = {
id: payload.sub,
username: payload.username,
nickname: payload.nickname,
type: payload.type,
id: String(payload.sub ?? ''),
username: String(payload.username ?? ''),
nickname: String(payload.nickname ?? ''),
type: payload.type === 'SUB' ? 'SUB' : 'MAIN',
}
} catch {
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">
<el-page-header @back="$router.back()" :content="app.name" style="margin-bottom:24px" />
<el-card style="margin-bottom:16px">
<el-descriptions :column="2" border>
<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">
@ -24,7 +24,7 @@
</el-descriptions>
</el-card>
<el-card style="margin-bottom:16px">
<el-card class="info-card" style="margin-bottom:16px">
<template #header>即时通讯服务</template>
<div class="service-grid">
<el-card class="service-card">
@ -54,41 +54,76 @@
</div>
</el-card>
<el-card>
<template #header>离线推送与版本管理</template>
<el-card class="info-card">
<template #header>离线推送</template>
<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-title-block">
<span class="service-name">{{ serviceLabel(svcType) }}</span>
<span class="service-help">{{ serviceHelp(svcType) }}</span>
</div>
<span class="service-name">{{ serviceLabel('PUSH') }}</span>
<span class="service-help">{{ serviceHelp('PUSH') }}</span>
</div>
<el-switch
:model-value="isServiceEnabled(svcType)"
@change="(val: boolean) => onToggleService(svcType, val)"
:model-value="isServiceEnabled('PUSH')"
@change="(val: boolean) => onToggleService('PUSH', val)"
/>
</div>
<div class="service-status-row">
<el-tag :type="isServiceEnabled(svcType) ? 'success' : 'info'" size="small">
{{ isServiceEnabled(svcType) ? '已开通' : '未开通' }}
<el-tag :type="isServiceEnabled('PUSH') ? 'success' : 'info'" size="small">
{{ isServiceEnabled('PUSH') ? '已开通' : '未开通' }}
</el-tag>
<span class="service-status-text">
{{ svcType === 'UPDATE'
? 'Android 整包版本在版本管理页上传;iOS / 鸿蒙仅记录版本号和市场跳转页。商店配置与发布配置都在版本管理页。'
: '推送服务开通后即可在终端接收设备级推送。' }}
推送服务开通后即可在终端接收设备级推送
</span>
</div>
<div class="service-actions" v-if="svcType === 'UPDATE'">
<el-button size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/update`)">
<div class="service-actions">
<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 v-else size="small" type="primary" plain @click="openActivationRequest('UPDATE')">
申请开通
</el-button>
</div>
</el-card>
</div>
</el-card>
<!-- 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">
<p style="color:#555;margin-bottom:16px">
{{ verifyPurpose === 'REVEAL_SECRET'
@ -115,7 +150,7 @@
</el-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-item label="服务">{{ serviceLabel(activationForm.serviceType) }}</el-form-item>
<el-form-item label="申请理由">
@ -131,7 +166,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
@ -143,6 +178,8 @@ const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const imService = computed(() => services.value.find(s => s.serviceType === 'IM') ?? null)
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)
@ -168,8 +205,8 @@ function serviceLabel(type: string) {
function serviceHelp(type: string) {
return {
IM: 'IM 服务独立开通后,在管理页配置回调和消息能力。',
PUSH: '一次开通后,推送配置在服务管理页按平台维护。',
UPDATE: '一次开通后,版本管理页只管理 Android 整包版本,iOS / 鸿蒙仅记录提醒信息。',
PUSH: '一次开通后,可在推送配置页按厂商维护配置。',
UPDATE: '一次开通后,版本管理页独立管理版本上传、商店配置和灰度发布。',
}[type] ?? ''
}
@ -273,11 +310,26 @@ function copy(text: string) {
ElMessage.success('已复制')
}
onMounted(loadData)
function updateViewport() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
loadData()
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script>
<style scoped>
.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-card { border: 1px solid #e8e8e8; }
.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-text { font-size: 13px; color: #6b7280; line-height: 1.5; }
.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>

查看文件

@ -7,22 +7,24 @@
</el-button>
</div>
<el-table :data="apps" v-loading="loading" style="width:100%">
<el-table-column prop="name" label="应用名称" />
<el-table-column prop="packageName" label="包名" />
<el-table-column prop="appKey" label="AppKey" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button>
<el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-wrap">
<el-table :data="apps" v-loading="loading" style="width:100%">
<el-table-column prop="name" label="应用名称" min-width="120" />
<el-table-column prop="packageName" label="包名" min-width="160" />
<el-table-column prop="appKey" label="AppKey" min-width="220" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button>
<el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</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-item label="包名" prop="packageName">
<el-input v-model="createForm.packageName" placeholder="com.example.app" />
@ -43,7 +45,7 @@
</template>
<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 type { FormInstance, FormRules } from 'element-plus'
import { appApi, type App } from '@/api/app'
@ -53,6 +55,8 @@ const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const createFormRef = ref<FormInstance>()
const isMobile = ref(false)
const dialogWidth = computed(() => (isMobile.value ? 'calc(100vw - 24px)' : '480px'))
const createForm = reactive({ packageName: '', name: '', description: '' })
const createRules: FormRules = {
@ -94,9 +98,43 @@ function formatDate(d: string) {
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>
<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>

查看文件

@ -90,10 +90,18 @@ onMounted(loadCaptcha)
display: flex;
align-items: 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 {
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 {
text-align: center;
@ -117,4 +125,33 @@ onMounted(loadCaptcha)
justify-content: space-between;
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>

查看文件

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

查看文件

@ -1,6 +1,6 @@
<template>
<el-container class="layout-container">
<el-aside width="220px" class="sidebar">
<el-aside v-if="!isMobile" width="220px" class="sidebar">
<div class="logo">
<span>XuqmGroup</span>
</div>
@ -10,24 +10,50 @@
background-color="#1d2129"
text-color="#c9d1d9"
active-text-color="#409eff"
class="nav-menu"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>控制台</span>
</el-menu-item>
<el-menu-item index="/apps">
<el-icon><Grid /></el-icon>
<span>我的应用</span>
</el-menu-item>
<el-menu-item index="/accounts" v-if="auth.user?.type === 'MAIN'">
<el-icon><User /></el-icon>
<span>子账号管理</span>
<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-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">
<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">
<el-tooltip content="开发者文档" placement="bottom">
<a href="/docs/" target="_blank" class="docs-link">
@ -51,7 +77,7 @@
</div>
</el-header>
<el-main>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
@ -59,12 +85,39 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { Document } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { Document, Grid, Menu, Odometer, User } from '@element-plus/icons-vue'
const auth = useAuthStore()
const route = useRoute()
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) {
if (cmd === 'logout') {
@ -72,14 +125,34 @@ function handleCommand(cmd: string) {
router.push('/login')
}
}
watch(
() => route.path,
() => {
drawerVisible.value = false
},
)
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script>
<style scoped>
.layout-container {
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
}
.sidebar {
background: #1d2129;
flex: 0 0 220px;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.08);
}
.logo {
height: 60px;
@ -91,18 +164,47 @@ function handleCommand(cmd: string) {
font-weight: bold;
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: 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 {
display: flex;
align-items: center;
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 {
display: flex;
align-items: center;
@ -128,4 +230,48 @@ function handleCommand(cmd: string) {
font-size: 14px;
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>

查看文件

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

查看文件

@ -6,7 +6,7 @@
<el-tabs v-model="activeTab">
<!-- App Versions -->
<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-button value="ANDROID">Android</el-radio-button>
<el-radio-button value="IOS">iOS</el-radio-button>
@ -16,6 +16,7 @@
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="appVersions" v-loading="loadingApp" border stripe>
<el-table-column prop="versionName" label="版本名" width="110" />
<el-table-column prop="versionCode" label="版本码" width="90" />
@ -33,13 +34,25 @@
<el-table-column label="应用商店" width="220" show-overflow-tooltip>
<template #default="{row}">
<template v-if="parseStoreReview(row.storeReviewStatus).length">
<el-tag
v-for="item in parseStoreReview(row.storeReviewStatus)"
:key="item.store"
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
<el-tooltip
v-if="item.state === 'REJECTED' && item.reason"
:content="item.reason"
placement="top"
>
<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>
<span v-else class="text-muted"></span>
</template>
@ -69,10 +82,14 @@
v-if="row.publishStatus === 'PUBLISHED'"
link type="warning" size="small"
@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
v-if="row.publishStatus === 'PUBLISHED'"
link type="danger" size="small"
@click="unpublishApp(row.id)">下架</el-button>
@click="promptUnpublishApp(row.id)">下架</el-button>
<el-button
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
link type="primary" size="small"
@ -80,11 +97,12 @@
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- RN Bundles -->
<el-tab-pane label="RN Bundle 热更新" name="rn">
<div class="toolbar">
<div class="toolbar responsive-toolbar">
<el-input
v-model="rnModuleFilter"
placeholder="模块ID可选"
@ -101,6 +119,7 @@
<el-button @click="loadRnBundles" :loading="loadingRn">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="rnBundles" v-loading="loadingRn" border stripe>
<el-table-column prop="moduleId" label="模块ID" width="140" />
<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 === '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="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>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- App Store Config -->
@ -226,7 +246,7 @@
:closable="false"
style="margin-bottom:16px"
/>
<div class="toolbar">
<div class="toolbar responsive-toolbar">
<el-button @click="loadPublishConfig" :loading="loadingPublishConfig">刷新</el-button>
<el-button type="primary" @click="savePublishConfig" :loading="savingPublishConfig">保存配置</el-button>
</div>
@ -235,7 +255,7 @@
<el-form-item label="默认灰度模式">
<el-radio-group v-model="publishConfigForm.grayMode">
<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-form-item>
<el-form-item v-if="publishConfigForm.grayMode === 'PERCENT'" label="默认灰度比例">
@ -245,9 +265,15 @@
<el-form-item label="成员选择回调">
<el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" />
</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-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" />
</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-radio-group v-model="publishConfigForm.graySelectionSource">
<el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button>
@ -260,11 +286,36 @@
</template>
</el-form>
</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-card>
<!-- 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-item label="发布方式">
<el-radio-group v-model="publishForm.publishImmediately">
@ -293,7 +344,7 @@
</el-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-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
<el-form-item label="灰度方式" v-if="grayForm.enabled">
@ -359,7 +410,7 @@
</el-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">
<p style="margin-bottom:12px">
版本 <strong>{{ submitStoreVersion.versionName }}</strong>
@ -414,7 +465,7 @@
<el-dialog
v-model="showStoreConfig"
:title="`配置 ${currentStoreDef?.label} 凭据`"
width="720px"
:width="dialogWidth"
>
<el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="150px" class="store-config-form">
<el-form-item label="启用">
@ -451,10 +502,10 @@
</el-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-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="IOS" label="iOS" />
<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-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 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-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk">
<el-button :loading="appPackageInspecting" :disabled="appPackageInspecting">
@ -475,17 +532,17 @@
</el-form-item>
<el-alert
v-if="appUploadForm.platform !== 'ANDROID'"
type="warning"
type="info"
:closable="false"
show-icon
title="iOS 和鸿蒙只记录版本信息,不需要上传安装包。包名会自动回显当前应用包名。"
title="iOS 和鸿蒙只记录版本信息,不需要上传安装包。商店跳转链接可选填写,方便后续跳转与回显。"
/>
<el-alert
v-if="appUploadForm.platform === 'ANDROID'"
type="info"
:closable="false"
show-icon
title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,会直接提示。"
title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,可选择强制继续使用。"
/>
</el-form>
<template #footer>
@ -495,7 +552,7 @@
</el-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-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onRnBundleChange" accept=".bundle,.js">
@ -533,7 +590,7 @@
</template>
<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 { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type App } from '@/api/app'
@ -561,6 +618,8 @@ const route = useRoute()
const appId = route.params.appId as string
const app = ref<App | null>(null)
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 storeTab = ref<'configs' | 'guide'>('configs')
@ -580,7 +639,9 @@ const publishConfigForm = ref({
grayMode: 'PERCENT' as GrayMode,
graySelectionSource: 'LOCAL' as GraySelectionSource,
graySelectCallbackUrl: '',
graySelectCallbackSecret: '',
grayDirectorySyncCallbackUrl: '',
grayDirectorySyncCallbackSecret: '',
})
const grayMembers = ref<GrayMemberGroup[]>([])
const loadingGrayMembers = ref(false)
@ -588,6 +649,18 @@ const grayMemberKeyword = ref('')
const grayMemberGroupFilter = ref('')
const grayMemberIds = ref<string[]>([])
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 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 GuideStep = { title: string; description: string }
function marketUrlField(placeholder: string): FieldDef {
return { key: 'marketUrl', label: '应用市场跳转页面', placeholder }
return { key: 'marketUrl', label: '应用市场跳转页面(可选)', placeholder }
}
type StoreDef = {
type: StoreType
@ -710,7 +783,7 @@ const STORE_DEFS: StoreDef[] = [
{ title: '保存 Client_id / 密钥', description: '与这里的 Client ID / Client Secret 对应。' },
],
jumpLinkHint: '荣耀应用的详情页链接请填写应用在荣耀市场的公开详情页地址。',
guideHint: '与后端 Honor 提交流程完全一致。',
guideHint: '与后端 Honor 提交流程完全一致;跳转页可选填写。',
guideImage: honorGuideImage,
},
{
@ -727,8 +800,8 @@ const STORE_DEFS: StoreDef[] = [
{ title: '复制 App Store 页面链接', description: '这里只需要保存跳转页,不需要再填密钥。' },
{ title: '保存配置', description: '配置完成后可在版本提醒中直接跳转。' },
],
jumpLinkHint: 'App Store 链接直接填写公开详情页 URL,通常以 apps.apple.com 开头。',
guideHint: '不再配置 Team ID、Key ID 或私钥,发布侧只使用跳转链接。',
jumpLinkHint: 'App Store 链接可选填写,通常以 apps.apple.com 开头,需要跳转时再补。',
guideHint: '不再配置 Team ID、Key ID 或私钥;发布侧只在需要跳转时使用该链接。',
},
{
type: 'GOOGLE_PLAY',
@ -762,7 +835,7 @@ const STORE_DEFS: StoreDef[] = [
{ title: '复制详情页链接', description: '填写市场跳转页面,便于版本提醒直接跳转。' },
{ title: '保存配置', description: '配置完成后可在版本提醒里引用。' },
],
jumpLinkHint: '鸿蒙应用市场通常使用 appgallery.huawei.com/app/detail?id=包名 的公开详情页链接。',
jumpLinkHint: '鸿蒙应用市场详情页链接可选填写,通常使用 appgallery.huawei.com/app/detail?id=包名。',
guideHint: '这里只保存鸿蒙应用市场的独立跳转页,不参与 Android 审核提交。',
},
{
@ -857,12 +930,6 @@ async function saveStoreConfig() {
ElMessage.warning('请填写审核通知地址')
return
}
} else {
const marketUrl = storeConfigForm.value.values.marketUrl?.trim() ?? ''
if (!marketUrl) {
ElMessage.warning('请填写应用市场跳转页面')
return
}
}
savingStoreConfig.value = true
try {
@ -901,7 +968,9 @@ function normalizePublishConfig(raw: Record<string, unknown> | null | undefined)
grayMode,
graySelectionSource,
graySelectCallbackUrl: String((raw as Record<string, unknown>)?.graySelectCallbackUrl ?? ''),
graySelectCallbackSecret: String((raw as Record<string, unknown>)?.graySelectCallbackSecret ?? ''),
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 或刷新页面查看')
showSubmitStore.value = false
await loadAppVersions()
await loadOperationLogs()
} catch {
ElMessage.error('提交失败')
} finally {
@ -1097,6 +1167,7 @@ async function submitGray() {
await updateAdminApi.grayRnBundle(id, payload)
await loadRnBundles()
}
await loadOperationLogs()
ElMessage.success('灰度配置已保存')
showGray.value = false
} finally {
@ -1112,10 +1183,40 @@ const appUploadForm = ref({
versionName: '',
versionCode: 1,
changeLog: '',
appStoreUrl: '',
marketUrl: '',
file: null as File | null,
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) {
const file = uploadFile?.raw ?? null
appUploadForm.value.file = file
@ -1131,14 +1232,22 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
const inspected = res.data.data as AppPackageInspectResult
const currentPackageName = app.value?.packageName?.trim() ?? ''
if (currentPackageName && inspected.packageName && inspected.packageName !== currentPackageName) {
await ElMessageBox.alert(
`当前应用包名是 ${currentPackageName},你选择的文件包名是 ${inspected.packageName}。请重新选择匹配的安装包。`,
'包名不一致',
{ type: 'warning', confirmButtonText: '知道了' },
)
appUploadForm.value.file = null
appUploadForm.value.fileUrl = ''
return
try {
await ElMessageBox.confirm(
`当前应用包名是 ${currentPackageName},你选择的文件包名是 ${inspected.packageName}。是否强制继续使用这个包?`,
'包名不一致',
{
type: 'warning',
confirmButtonText: '强制使用',
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.packageName) appUploadForm.value.packageName = inspected.packageName
@ -1165,6 +1274,8 @@ async function submitAppUpload() {
fd.append('versionCode', String(f.versionCode))
if (f.packageName) fd.append('packageName', f.packageName)
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)
await updateAdminApi.uploadAppVersion(fd)
ElMessage.success('上传成功')
@ -1320,6 +1431,7 @@ async function submitPublish() {
await updateAdminApi.publishRnBundle(publishTarget.value.id, body)
await loadRnBundles()
}
await loadOperationLogs()
ElMessage.success('已保存发布操作')
showPublish.value = false
} catch {
@ -1329,16 +1441,56 @@ async function submitPublish() {
}
}
async function unpublishApp(id: string) {
await updateAdminApi.unpublishAppVersion(id)
ElMessage.success('已下架')
await loadAppVersions()
async function promptUnpublishReason(title: string) {
let value = ''
try {
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) {
await updateAdminApi.unpublishRnBundle(id)
ElMessage.success('已下架')
await loadRnBundles()
async function promptUnpublishApp(id: string) {
const reason = await promptUnpublishReason('下架确认')
if (!reason) return
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) {
@ -1365,11 +1517,78 @@ function reviewTagType(state: string): string {
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 []
try {
const m = JSON.parse(json) as Record<string, string>
return Object.entries(m).map(([store, state]) => ({ store, state }))
const m = JSON.parse(json) as Record<string, unknown>
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 {
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], () => {
if (showGray.value && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL') {
loadGrayMembers()
@ -1394,16 +1622,35 @@ watch(grayForm, () => {
}, { deep: true })
onMounted(() => {
updateViewport()
window.addEventListener('resize', updateViewport)
loadApp()
loadAppVersions()
loadRnBundles()
loadStoreConfigs()
loadPublishConfig()
loadOperationLogs()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport)
})
</script>
<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; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-left: 8px; }
@ -1519,4 +1766,50 @@ onMounted(() => {
justify-content: space-between;
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>