docs(deploy): 添加生产环境部署配置示例和部署文档
- 新增 .env.production.example 环境变量配置模板 - 添加 compose.production.yaml Docker Compose 部署配置 - 创建 web.Dockerfile 前端构建部署文件 - 编写详细的 README.md 部署文档,涵盖架构、配置、步骤等内容 - 添加离线推送架构设计文档 - 更新 IM 多平台进度跟踪文档
这个提交包含在:
父节点
e47f510a0b
当前提交
c2ff993e05
1
ops-platform/components.d.ts
vendored
1
ops-platform/components.d.ts
vendored
@ -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>
|
||||
|
||||
1
tenant-platform/components.d.ts
vendored
1
tenant-platform/components.d.ts
vendored
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
tenant-platform/src/utils/jwt.ts
普通文件
20
tenant-platform/src/utils/jwt.ts
普通文件
@ -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: 'APNs(iOS)',
|
||||
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>
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户