feat: IM management + version management pages in tenant platform
- ImManagementView: user list with ban/unban, group list, stats cards - VersionManagementView: App整包 and RN Bundle tabs with upload / publish / unpublish / gray release dialogs - Add /apps/:appId/im and /apps/:appId/update routes - AppDetailView: add "即时通讯管理 →" and "版本管理 →" links on service cards - Add api/im.ts and api/update.ts clients - Add .nvmrc (node 22) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
459edbca7c
当前提交
74319eee4f
60
tenant-platform/src/api/im.ts
普通文件
60
tenant-platform/src/api/im.ts
普通文件
@ -0,0 +1,60 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const imClient = axios.create({
|
||||
baseURL: 'https://sentry.xuqinmin.com',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
imClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export interface ImUser {
|
||||
id: string
|
||||
appId: string
|
||||
userId: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
status: 'ACTIVE' | 'BANNED'
|
||||
gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ImGroup {
|
||||
id: string
|
||||
appId: string
|
||||
name: string
|
||||
creatorId: string
|
||||
memberIds: string[]
|
||||
adminIds: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ImStats {
|
||||
totalMessages: number
|
||||
totalUsers: number
|
||||
totalGroups: number
|
||||
todayMessages: number
|
||||
}
|
||||
|
||||
export const imAdminApi = {
|
||||
listUsers(appId: string, page = 0, size = 20) {
|
||||
return imClient.get<{ data: { content: ImUser[]; totalElements: number; totalPages: number } }>(
|
||||
'/api/im/admin/users', { params: { appId, page, size } },
|
||||
)
|
||||
},
|
||||
|
||||
updateUserStatus(appId: string, userId: string, status: 'ACTIVE' | 'BANNED') {
|
||||
return imClient.put(`/api/im/admin/users/${userId}/status`, { status }, { params: { appId } })
|
||||
},
|
||||
|
||||
listGroups(appId: string) {
|
||||
return imClient.get<{ data: ImGroup[] }>('/api/im/admin/groups', { params: { appId } })
|
||||
},
|
||||
|
||||
getStats(appId: string) {
|
||||
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const updateClient = axios.create({
|
||||
baseURL: 'https://sentry.xuqinmin.com',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
updateClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export interface AppVersion {
|
||||
id: string
|
||||
appId: string
|
||||
platform: 'ANDROID' | 'IOS'
|
||||
versionName: string
|
||||
versionCode: number
|
||||
downloadUrl?: string
|
||||
changeLog?: string
|
||||
forceUpdate: boolean
|
||||
publishStatus: 'DRAFT' | 'PUBLISHED' | 'DEPRECATED'
|
||||
grayEnabled: boolean
|
||||
grayPercent: number
|
||||
appStoreUrl?: string
|
||||
marketUrl?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface RnBundle {
|
||||
id: string
|
||||
appId: string
|
||||
moduleId: string
|
||||
platform: 'ANDROID' | 'IOS'
|
||||
version: string
|
||||
md5: string
|
||||
minCommonVersion?: string
|
||||
note?: string
|
||||
publishStatus: 'DRAFT' | 'PUBLISHED' | 'DEPRECATED'
|
||||
grayEnabled: boolean
|
||||
grayPercent: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const updateAdminApi = {
|
||||
listAppVersions(appId: string, platform: 'ANDROID' | 'IOS') {
|
||||
return updateClient.get<{ data: AppVersion[] }>('/api/v1/updates/app/list', {
|
||||
params: { appId, platform },
|
||||
})
|
||||
},
|
||||
|
||||
publishAppVersion(id: string) {
|
||||
return updateClient.post(`/api/v1/updates/app/${id}/publish`)
|
||||
},
|
||||
|
||||
unpublishAppVersion(id: string) {
|
||||
return updateClient.post(`/api/v1/updates/app/${id}/unpublish`)
|
||||
},
|
||||
|
||||
grayAppVersion(id: string, enabled: boolean, percent: number) {
|
||||
return updateClient.post(`/api/v1/updates/app/${id}/gray`, { enabled, percent })
|
||||
},
|
||||
|
||||
uploadAppVersion(formData: FormData) {
|
||||
return updateClient.post<{ data: AppVersion }>('/api/v1/updates/app/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
|
||||
listRnBundles(appId: string, moduleId?: string, platform?: string) {
|
||||
return updateClient.get<{ data: RnBundle[] }>('/api/v1/rn/list', {
|
||||
params: { appId, ...(moduleId && { moduleId }), ...(platform && { platform }) },
|
||||
})
|
||||
},
|
||||
|
||||
publishRnBundle(id: string) {
|
||||
return updateClient.post(`/api/v1/rn/${id}/publish`)
|
||||
},
|
||||
|
||||
unpublishRnBundle(id: string) {
|
||||
return updateClient.post(`/api/v1/rn/${id}/unpublish`)
|
||||
},
|
||||
|
||||
grayRnBundle(id: string, enabled: boolean, percent: number) {
|
||||
return updateClient.post(`/api/v1/rn/${id}/gray`, { enabled, percent })
|
||||
},
|
||||
|
||||
uploadRnBundle(formData: FormData) {
|
||||
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -37,6 +37,14 @@ const router = createRouter({
|
||||
path: 'apps/:id',
|
||||
component: () => import('@/views/apps/AppDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps/:appId/im',
|
||||
component: () => import('@/views/im/ImManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps/:appId/update',
|
||||
component: () => import('@/views/update/VersionManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
component: () => import('@/views/accounts/SubAccountView.vue'),
|
||||
|
||||
@ -49,6 +49,20 @@
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<el-button
|
||||
v-if="svcType === 'IM'"
|
||||
size="small"
|
||||
@click="$router.push(`/apps/${route.params.id}/im`)">
|
||||
即时通讯管理 →
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="svcType === 'UPDATE'"
|
||||
size="small"
|
||||
@click="$router.push(`/apps/${route.params.id}/update`)">
|
||||
版本管理 →
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-page-header @back="$router.back()" :content="`即时通讯管理 — ${appId}`" style="margin-bottom:20px" />
|
||||
|
||||
<!-- Stats -->
|
||||
<el-row :gutter="16" style="margin-bottom:20px">
|
||||
<el-col :span="6" v-for="item in statCards" :key="item.label">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ item.value }}</span>
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<!-- Users Tab -->
|
||||
<el-tab-pane label="注册用户" name="users">
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loadingUsers" border stripe>
|
||||
<el-table-column prop="userId" label="用户ID" width="160" />
|
||||
<el-table-column prop="nickname" label="昵称" width="120" />
|
||||
<el-table-column prop="gender" label="性别" width="80">
|
||||
<template #default="{row}">
|
||||
{{ { UNKNOWN: '未知', MALE: '男', FEMALE: '女' }[row.gender] }}
|
||||
</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="120" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
size="small"
|
||||
@click="toggleUserStatus(row)">
|
||||
{{ row.status === 'ACTIVE' ? '封禁' : '解封' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="userTotal > userPageSize"
|
||||
style="margin-top:16px"
|
||||
layout="prev, pager, next"
|
||||
:total="userTotal"
|
||||
:page-size="userPageSize"
|
||||
:current-page="userPage + 1"
|
||||
@current-change="p => { userPage = p - 1; loadUsers() }"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<el-tab-pane label="群组列表" name="groups">
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="groups" v-loading="loadingGroups" border stripe>
|
||||
<el-table-column prop="id" label="群组ID" width="240" />
|
||||
<el-table-column prop="name" label="群名称" />
|
||||
<el-table-column prop="creatorId" label="创建者" width="160" />
|
||||
<el-table-column label="成员数" width="100">
|
||||
<template #default="{row}">{{ (row.memberIds ?? []).length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { imAdminApi, type ImUser, type ImGroup, type ImStats } from '@/api/im'
|
||||
|
||||
const route = useRoute()
|
||||
const appId = route.params.appId as string
|
||||
|
||||
const activeTab = ref('users')
|
||||
const stats = ref<ImStats | null>(null)
|
||||
|
||||
const users = ref<ImUser[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const userPage = ref(0)
|
||||
const userPageSize = 20
|
||||
const userTotal = ref(0)
|
||||
|
||||
const groups = ref<ImGroup[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
|
||||
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
|
||||
{ label: '消息总量', value: stats.value?.totalMessages ?? '-' },
|
||||
{ label: '今日消息', value: stats.value?.todayMessages ?? '-' },
|
||||
])
|
||||
|
||||
function formatTime(t: string) {
|
||||
return t ? new Date(t).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await imAdminApi.getStats(appId)
|
||||
stats.value = res.data.data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
const res = await imAdminApi.listUsers(appId, userPage.value, userPageSize)
|
||||
users.value = res.data.data.content
|
||||
userTotal.value = res.data.data.totalElements
|
||||
} catch (e) {
|
||||
ElMessage.error('加载用户失败')
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const res = await imAdminApi.listGroups(appId)
|
||||
groups.value = res.data.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载群组失败')
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: ImUser) {
|
||||
const newStatus = user.status === 'ACTIVE' ? 'BANNED' : 'ACTIVE'
|
||||
const action = newStatus === 'BANNED' ? '封禁' : '解封'
|
||||
await ElMessageBox.confirm(`确认${action}用户 ${user.userId}?`, `${action}用户`, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await imAdminApi.updateUserStatus(appId, user.userId, newStatus)
|
||||
ElMessage.success(`已${action}`)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
if (tab === 'groups' && groups.value.length === 0) loadGroups()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; }
|
||||
.stat-value { font-size: 28px; font-weight: 800; color: #1f2933; }
|
||||
.stat-label { font-size: 13px; color: #6b7280; }
|
||||
.toolbar { margin-bottom: 12px; }
|
||||
</style>
|
||||
@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-page-header @back="$router.back()" :content="`版本管理 — ${appId}`" style="margin-bottom:20px" />
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- App Versions -->
|
||||
<el-tab-pane label="App 整包版本" name="app">
|
||||
<div class="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>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" @click="showUploadApp = true">上传新版本</el-button>
|
||||
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="appVersions" v-loading="loadingApp" border stripe>
|
||||
<el-table-column prop="versionName" label="版本名" width="120" />
|
||||
<el-table-column prop="versionCode" label="版本码" width="100" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
|
||||
<el-tag v-if="row.grayEnabled" type="warning" size="small" style="margin-left:4px">
|
||||
灰度 {{ row.grayPercent }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="forceUpdate" label="强制更新" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.forceUpdate ? 'danger' : 'info'" size="small">
|
||||
{{ row.forceUpdate ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changeLog" label="更新说明" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="上传时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button
|
||||
v-if="row.publishStatus === 'DRAFT'"
|
||||
link type="success" size="small"
|
||||
@click="publishApp(row.id)">发布</el-button>
|
||||
<el-button
|
||||
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="danger" size="small"
|
||||
@click="unpublishApp(row.id)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- RN Bundles -->
|
||||
<el-tab-pane label="RN Bundle 热更新" name="rn">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="rnModuleFilter"
|
||||
placeholder="模块ID(可选)"
|
||||
clearable
|
||||
style="width:180px;margin-right:8px"
|
||||
@change="loadRnBundles"
|
||||
/>
|
||||
<el-radio-group v-model="rnPlatform" @change="loadRnBundles" style="margin-right:12px">
|
||||
<el-radio-button value="">全平台</el-radio-button>
|
||||
<el-radio-button value="ANDROID">Android</el-radio-button>
|
||||
<el-radio-button value="IOS">iOS</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" @click="showUploadRn = true">上传 Bundle</el-button>
|
||||
<el-button @click="loadRnBundles" :loading="loadingRn">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<el-table-column prop="platform" label="平台" width="90" />
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
|
||||
<el-tag v-if="row.grayEnabled" type="warning" size="small" style="margin-left:4px">
|
||||
灰度 {{ row.grayPercent }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="minCommonVersion" label="最低 Common 版本" width="160" />
|
||||
<el-table-column prop="note" label="说明" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="上传时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button
|
||||
v-if="row.publishStatus === 'DRAFT'"
|
||||
link type="success" size="small"
|
||||
@click="publishRn(row.id)">发布</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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- Gray Release Dialog -->
|
||||
<el-dialog v-model="showGray" title="灰度发布配置" width="400px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="开启灰度">
|
||||
<el-switch v-model="grayForm.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="灰度比例" v-if="grayForm.enabled">
|
||||
<el-slider v-model="grayForm.percent" :min="1" :max="100" show-input />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showGray = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitGray" :loading="submittingGray">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Upload App Version Dialog -->
|
||||
<el-dialog v-model="showUploadApp" title="上传 App 版本" width="480px">
|
||||
<el-form :model="appUploadForm" label-width="100px">
|
||||
<el-form-item label="平台">
|
||||
<el-select v-model="appUploadForm.platform">
|
||||
<el-option value="ANDROID" label="Android" />
|
||||
<el-option value="IOS" label="iOS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" /></el-form-item>
|
||||
<el-form-item label="版本码"><el-input-number v-model="appUploadForm.versionCode" :min="1" /></el-form-item>
|
||||
<el-form-item label="强制更新"><el-switch v-model="appUploadForm.forceUpdate" /></el-form-item>
|
||||
<el-form-item label="更新说明"><el-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="APK 文件">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="f => appUploadForm.file = f.raw ?? null"
|
||||
accept=".apk,.ipa">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadApp = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAppUpload" :loading="uploadingApp">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Upload RN Bundle Dialog -->
|
||||
<el-dialog v-model="showUploadRn" title="上传 RN Bundle" width="480px">
|
||||
<el-form :model="rnUploadForm" label-width="100px">
|
||||
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" /></el-form-item>
|
||||
<el-form-item label="平台">
|
||||
<el-select v-model="rnUploadForm.platform">
|
||||
<el-option value="ANDROID" label="Android" />
|
||||
<el-option value="IOS" label="iOS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="如 1.0.1" /></el-form-item>
|
||||
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="如 1.0.0" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="Bundle 文件">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="f => rnUploadForm.file = f.raw ?? null"
|
||||
accept=".bundle,.js">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadRn = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRnUpload" :loading="uploadingRn">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { updateAdminApi, type AppVersion, type RnBundle } from '@/api/update'
|
||||
|
||||
const route = useRoute()
|
||||
const appId = route.params.appId as string
|
||||
|
||||
const activeTab = ref('app')
|
||||
const appPlatform = ref<'ANDROID' | 'IOS'>('ANDROID')
|
||||
const rnPlatform = ref('')
|
||||
const rnModuleFilter = ref('')
|
||||
|
||||
const appVersions = ref<AppVersion[]>([])
|
||||
const loadingApp = ref(false)
|
||||
const rnBundles = ref<RnBundle[]>([])
|
||||
const loadingRn = ref(false)
|
||||
|
||||
const showGray = ref(false)
|
||||
const submittingGray = ref(false)
|
||||
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
|
||||
const grayForm = ref({ enabled: true, percent: 10 })
|
||||
|
||||
const showUploadApp = ref(false)
|
||||
const uploadingApp = ref(false)
|
||||
const appUploadForm = ref({
|
||||
platform: 'ANDROID' as 'ANDROID' | 'IOS',
|
||||
versionName: '',
|
||||
versionCode: 1,
|
||||
forceUpdate: false,
|
||||
changeLog: '',
|
||||
file: null as File | null,
|
||||
})
|
||||
|
||||
const showUploadRn = ref(false)
|
||||
const uploadingRn = ref(false)
|
||||
const rnUploadForm = ref({
|
||||
moduleId: '',
|
||||
platform: 'ANDROID' as 'ANDROID' | 'IOS',
|
||||
version: '',
|
||||
minCommonVersion: '',
|
||||
note: '',
|
||||
file: null as File | null,
|
||||
})
|
||||
|
||||
function formatTime(t: string) {
|
||||
return t ? new Date(t).toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
function statusLabel(row: { publishStatus: string }) {
|
||||
return { DRAFT: '草稿', PUBLISHED: '已发布', DEPRECATED: '已下架' }[row.publishStatus] ?? row.publishStatus
|
||||
}
|
||||
|
||||
function statusTagType(row: { publishStatus: string }) {
|
||||
return { DRAFT: '', PUBLISHED: 'success', DEPRECATED: 'info' }[row.publishStatus] ?? ''
|
||||
}
|
||||
|
||||
async function loadAppVersions() {
|
||||
loadingApp.value = true
|
||||
try {
|
||||
const res = await updateAdminApi.listAppVersions(appId, appPlatform.value)
|
||||
appVersions.value = res.data.data
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
finally { loadingApp.value = false }
|
||||
}
|
||||
|
||||
async function loadRnBundles() {
|
||||
loadingRn.value = true
|
||||
try {
|
||||
const res = await updateAdminApi.listRnBundles(
|
||||
appId,
|
||||
rnModuleFilter.value || undefined,
|
||||
rnPlatform.value || undefined,
|
||||
)
|
||||
rnBundles.value = res.data.data
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
finally { loadingRn.value = false }
|
||||
}
|
||||
|
||||
async function publishApp(id: string) {
|
||||
await updateAdminApi.publishAppVersion(id)
|
||||
ElMessage.success('已发布')
|
||||
loadAppVersions()
|
||||
}
|
||||
|
||||
async function unpublishApp(id: string) {
|
||||
await updateAdminApi.unpublishAppVersion(id)
|
||||
ElMessage.success('已下架')
|
||||
loadAppVersions()
|
||||
}
|
||||
|
||||
async function publishRn(id: string) {
|
||||
await updateAdminApi.publishRnBundle(id)
|
||||
ElMessage.success('已发布')
|
||||
loadRnBundles()
|
||||
}
|
||||
|
||||
async function unpublishRn(id: string) {
|
||||
await updateAdminApi.unpublishRnBundle(id)
|
||||
ElMessage.success('已下架')
|
||||
loadRnBundles()
|
||||
}
|
||||
|
||||
function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
|
||||
grayTarget.value = { id: row.id, type }
|
||||
grayForm.value = { enabled: true, percent: 10 }
|
||||
showGray.value = true
|
||||
}
|
||||
|
||||
async function submitGray() {
|
||||
if (!grayTarget.value) return
|
||||
submittingGray.value = true
|
||||
try {
|
||||
const { id, type } = grayTarget.value
|
||||
if (type === 'app') {
|
||||
await updateAdminApi.grayAppVersion(id, grayForm.value.enabled, grayForm.value.percent)
|
||||
loadAppVersions()
|
||||
} else {
|
||||
await updateAdminApi.grayRnBundle(id, grayForm.value.enabled, grayForm.value.percent)
|
||||
loadRnBundles()
|
||||
}
|
||||
ElMessage.success('灰度配置已保存')
|
||||
showGray.value = false
|
||||
} finally { submittingGray.value = false }
|
||||
}
|
||||
|
||||
async function submitAppUpload() {
|
||||
const f = appUploadForm.value
|
||||
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
|
||||
uploadingApp.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('appId', appId)
|
||||
fd.append('platform', f.platform)
|
||||
fd.append('versionName', f.versionName)
|
||||
fd.append('versionCode', String(f.versionCode))
|
||||
fd.append('forceUpdate', String(f.forceUpdate))
|
||||
if (f.changeLog) fd.append('changeLog', f.changeLog)
|
||||
if (f.file) fd.append('apkFile', f.file)
|
||||
await updateAdminApi.uploadAppVersion(fd)
|
||||
ElMessage.success('上传成功,版本已创建为草稿')
|
||||
showUploadApp.value = false
|
||||
loadAppVersions()
|
||||
} finally { uploadingApp.value = false }
|
||||
}
|
||||
|
||||
async function submitRnUpload() {
|
||||
const f = rnUploadForm.value
|
||||
if (!f.moduleId || !f.version || !f.file) return ElMessage.warning('请填写模块ID、版本和 Bundle 文件')
|
||||
uploadingRn.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('appId', appId)
|
||||
fd.append('moduleId', f.moduleId)
|
||||
fd.append('platform', f.platform)
|
||||
fd.append('version', f.version)
|
||||
if (f.minCommonVersion) fd.append('minCommonVersion', f.minCommonVersion)
|
||||
if (f.note) fd.append('note', f.note)
|
||||
fd.append('bundle', f.file)
|
||||
await updateAdminApi.uploadRnBundle(fd)
|
||||
ElMessage.success('上传成功,Bundle 已创建为草稿')
|
||||
showUploadRn.value = false
|
||||
loadRnBundles()
|
||||
} finally { uploadingRn.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAppVersions()
|
||||
loadRnBundles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
</style>
|
||||
正在加载...
在新工单中引用
屏蔽一个用户