feat(deploy): 添加生产环境部署配置和联调环境切换功能

- 新增 .env.production.example 配置文件,包含所有微服务的数据库和Redis配置
- 添加 compose.production.yaml Docker Compose部署文件,定义web和各服务容器
- 实现Android SDK环境切换功能,支持外部服务和本地联调模式切换
- 添加推送注册状态管理和接收开关设置界面
- 集成演示服务的应用密钥客户端和认证服务实现
- 完善文档说明各SDK模块的集成和使用方法
这个提交包含在:
XuqmGroup 2026-04-30 11:47:01 +08:00
父节点 c2ff993e05
当前提交 92eb1ed539
共有 12 个文件被更改,包括 428 次插入111 次删除

1
Jenkinsfile vendored
查看文件

@ -2,6 +2,7 @@ pipeline {
agent any
parameters {
string(name: 'BRANCH', defaultValue: 'main', description: 'Git 分支名')
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag')
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署')
}

查看文件

@ -36,7 +36,7 @@ export default defineConfig(({ mode }) => {
server: {
port: 5174,
proxy: {
'/api': { target: 'http://192.168.116.9:8081', changeOrigin: true },
'/api': { target: 'http://127.0.0.1:8081', changeOrigin: true },
},
},
}

查看文件

@ -42,6 +42,7 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']

查看文件

@ -95,10 +95,17 @@ export const appApi = {
getServices: (appId: string) =>
client.get<{ data: FeatureService[] }>(`/apps/${appId}/services`),
getService: (appId: string, platform: string, serviceType: string) =>
client.get<{ data: FeatureService }>(`/apps/${appId}/services/item`, {
params: { platform, serviceType },
}),
getService: async (appId: string, platform: string, serviceType: string) => {
const res = await client.get<{ data: FeatureService[] }>(`/apps/${appId}/services`)
const service = res.data.data.find(item => item.platform === platform && item.serviceType === serviceType)
if (!service) {
throw new Error('服务不存在')
}
return {
...res,
data: { data: service },
} as typeof res & { data: { data: FeatureService } }
},
toggleService: (appId: string, platform: string, serviceType: string, enable: boolean) =>
client.post<{ data: FeatureService }>(`/apps/${appId}/services/toggle`, null, {

查看文件

@ -0,0 +1,11 @@
import client from './client'
export interface DashboardStats {
appCount: number
serviceCount: number
subAccountCount: number
}
export const dashboardApi = {
stats: () => client.get<{ data: DashboardStats }>('/dashboard/stats'),
}

查看文件

@ -1,7 +1,12 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { isJwtExpired } from '@/utils/jwt'
export type UploadProgressHandler = (percent: number) => void
const fileClient = axios.create({
baseURL: import.meta.env.VITE_FILE_SERVICE_URL ?? 'http://192.168.116.9:8086',
baseURL: import.meta.env.VITE_FILE_SERVICE_URL ?? '',
timeout: 30000,
})
@ -25,12 +30,44 @@ if (import.meta.env.DEV) {
fileClient.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
})
fileClient.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status
if (status === 401) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
ElMessage.error('登录已失效,请重新登录')
return Promise.reject(error)
}
if (status === 403) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
ElMessage.error(error.response?.data?.message ?? '登录已失效,请重新登录')
return Promise.reject(error)
}
const msg = error.response?.data?.message ?? '文件请求失败'
ElMessage.error(msg)
return Promise.reject(error)
},
)
export interface FileUploadResult {
url: string
thumbnailUrl?: string
@ -42,12 +79,19 @@ export interface FileUploadResult {
}
export const fileApi = {
uploadFile(file: File, thumbnail?: File | null) {
uploadFile(file: File, thumbnail?: File | null, onProgress?: UploadProgressHandler) {
const formData = new FormData()
formData.append('file', file)
if (thumbnail) {
formData.append('thumbnail', thumbnail)
}
return fileClient.post<{ data: FileUploadResult }>('/api/file/upload', formData)
return fileClient.post<{ data: FileUploadResult }>('/api/file/upload', formData, {
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total ?? 0
if (!total) return
onProgress(Math.min(100, Math.round((event.loaded * 100) / total)))
},
})
},
}

查看文件

@ -1,7 +1,10 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { isJwtExpired } from '@/utils/jwt'
const imClient = axios.create({
baseURL: import.meta.env.VITE_IM_API_BASE_URL ?? 'http://192.168.116.9:8082',
baseURL: import.meta.env.VITE_IM_API_BASE_URL ?? '',
timeout: 15000,
})
@ -25,10 +28,44 @@ if (import.meta.env.DEV) {
imClient.interceptors.request.use((config) => {
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 (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
return Promise.reject(new Error('登录已失效,请重新登录'))
}
return config
})
imClient.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status
if (status === 401) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
ElMessage.error('登录已失效,请重新登录')
return Promise.reject(error)
}
if (status === 403) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login?reason=' + encodeURIComponent('登录已失效,请重新登录'))
}
ElMessage.error(error.response?.data?.message ?? '登录已失效,请重新登录')
return Promise.reject(error)
}
const msg = error.response?.data?.message ?? 'IM 请求失败'
ElMessage.error(msg)
return Promise.reject(error)
},
)
export interface ImUser {
id: string
appId: string

查看文件

@ -1,4 +1,4 @@
import axios from 'axios'
import axios, { type AxiosProgressEvent } from 'axios'
import { isJwtExpired } from '@/utils/jwt'
const updateClient = axios.create({
@ -6,6 +6,19 @@ const updateClient = axios.create({
timeout: 30000,
})
type UploadProgressHandler = (percent: number) => void
function uploadProgressConfig(onProgress?: UploadProgressHandler) {
if (!onProgress) return {}
return {
onUploadProgress: (event: AxiosProgressEvent) => {
const total = event.total ?? 0
if (!total) return
onProgress(Math.min(100, Math.round((event.loaded * 100) / total)))
},
}
}
if (import.meta.env.DEV) {
updateClient.interceptors.request.use((config) => {
console.debug('[tenant-platform][UPDATE] request', {
@ -232,8 +245,8 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/updates/app/${id}/gray`, body)
},
uploadAppVersion(formData: FormData) {
return updateClient.post<{ data: AppVersion }>('/api/v1/updates/app/upload', formData)
uploadAppVersion(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post<{ data: AppVersion }>('/api/v1/updates/app/upload', formData, uploadProgressConfig(onProgress))
},
inspectAppPackage(apkUrl: string) {
@ -266,16 +279,16 @@ export const updateAdminApi = {
return updateClient.post(`/api/v1/rn/${id}/gray`, body)
},
uploadRnBundle(formData: FormData) {
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData)
uploadRnBundle(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData, uploadProgressConfig(onProgress))
},
inspectRnBundle(formData: FormData) {
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData)
inspectRnBundle(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData, uploadProgressConfig(onProgress))
},
uploadUnifiedRelease(formData: FormData) {
return updateClient.post('/api/v1/updates/unified/upload', formData)
uploadUnifiedRelease(formData: FormData, onProgress?: UploadProgressHandler) {
return updateClient.post('/api/v1/updates/unified/upload', formData, uploadProgressConfig(onProgress))
},
// ── Store config ────────────────────────────────────────────────────────

查看文件

@ -45,25 +45,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { appApi } from '@/api/app'
import { accountApi } from '@/api/account'
import { dashboardApi } from '@/api/dashboard'
const stats = ref({ appCount: 0, serviceCount: 0, subAccountCount: 0 })
onMounted(async () => {
try {
const [appsRes, accountsRes] = await Promise.all([
appApi.list(), accountApi.list(),
])
stats.value.appCount = appsRes.data.data.length
stats.value.subAccountCount = accountsRes.data.data.length
let serviceCount = 0
for (const app of appsRes.data.data) {
const svcRes = await appApi.getServices(app.id)
serviceCount += svcRes.data.data.filter(s => s.enabled).length
}
stats.value.serviceCount = serviceCount
const res = await dashboardApi.stats()
stats.value = res.data.data
} catch {}
})
</script>

查看文件

@ -3,98 +3,206 @@
<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>
<el-tabs v-model="activeSource">
<el-tab-pane label="租户平台" name="TENANT">
<div class="toolbar responsive-toolbar">
<el-select
v-model="tenantFilters.moduleType"
placeholder="模块"
style="width: 180px"
clearable
@change="handleTenantModuleChange"
>
<el-option label="全部模块" value="" />
<el-option label="控制台" value="CONSOLE" />
<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="tenantLoading" @click="loadTenantLogs">刷新</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>
<div class="table-wrap">
<el-table :data="tenantLogs" v-loading="tenantLoading" 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">{{ tenantModuleLabel(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-pagination
style="margin-top: 16px"
layout="total, prev, pager, next"
:total="tenantTotal"
:page-size="tenantPageSize"
:current-page="tenantPage + 1"
@current-change="handleTenantPageChange"
/>
</el-tab-pane>
<el-tab-pane label="版本管理" name="UPDATE">
<div class="toolbar responsive-toolbar">
<el-select
v-model="updateAppId"
placeholder="选择应用"
style="width: 320px"
filterable
@change="handleUpdateAppChange"
>
<el-option
v-for="app in apps"
:key="app.id"
:label="`${app.name} · ${app.packageName}`"
:value="app.id"
/>
</el-select>
<el-input-number v-model="updateLimit" :min="20" :max="200" :step="10" controls-position="right" />
<el-button :loading="updateLoading" @click="loadUpdateLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="updateLogs" v-loading="updateLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="140">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ updateResourceLabel(row.resourceType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="action" label="动作" width="160">
<template #default="{ row }">{{ updateActionLabel(row.action) }}</template>
</el-table-column>
<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="320">
<template #default="{ row }">
<span class="detail">{{ row.reason || formatDetail(row.detailJson) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { appApi, type App } from '@/api/app'
import { updateAdminApi, type OperationLog as UpdateOperationLog } from '@/api/update'
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 activeSource = ref<'TENANT' | 'UPDATE'>('TENANT')
const filters = reactive({
const tenantLoading = ref(false)
const tenantLogs = ref<TenantOperationLog[]>([])
const tenantTotal = ref(0)
const tenantPage = ref(0)
const tenantPageSize = ref(20)
const tenantFilters = reactive({
moduleType: '',
})
onMounted(() => {
loadLogs()
const updateLoading = ref(false)
const updateLogs = ref<UpdateOperationLog[]>([])
const updateLimit = ref(100)
const apps = ref<App[]>([])
const updateAppId = ref('')
onMounted(async () => {
await Promise.all([
loadApps(),
loadTenantLogs(),
])
})
async function loadLogs() {
loading.value = true
watch(activeSource, async (value) => {
if (value === 'UPDATE' && !apps.value.length) {
await loadApps()
}
if (value === 'UPDATE') {
await loadUpdateLogs()
}
})
async function loadApps() {
if (apps.value.length) return
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
const res = await appApi.list()
apps.value = res.data.data
if (!updateAppId.value && apps.value.length) {
updateAppId.value = apps.value[0].id
}
} catch {
// ignore; empty state will be shown in the selector
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage - 1
loadLogs()
async function loadTenantLogs() {
tenantLoading.value = true
try {
const res = await operationLogApi.list({
moduleType: tenantFilters.moduleType || undefined,
page: tenantPage.value,
size: tenantPageSize.value,
})
const data = res.data.data
tenantLogs.value = data.content ?? []
tenantTotal.value = data.totalElements ?? 0
} finally {
tenantLoading.value = false
}
}
function handleModuleChange() {
page.value = 0
loadLogs()
async function loadUpdateLogs() {
if (!updateAppId.value) {
updateLogs.value = []
return
}
updateLoading.value = true
try {
const res = await updateAdminApi.listOperationLogs(updateAppId.value, updateLimit.value)
updateLogs.value = res.data.data ?? []
} finally {
updateLoading.value = false
}
}
function moduleLabel(moduleType: string) {
function handleTenantPageChange(nextPage: number) {
tenantPage.value = nextPage - 1
loadTenantLogs()
}
function handleTenantModuleChange() {
tenantPage.value = 0
loadTenantLogs()
}
function handleUpdateAppChange() {
loadUpdateLogs()
}
function tenantModuleLabel(moduleType: string) {
return {
CONSOLE: '控制台',
APP: '应用管理',
SUB_ACCOUNT: '子账号管理',
SERVICE: '服务管理',
@ -103,11 +211,45 @@ function moduleLabel(moduleType: string) {
}[moduleType] ?? moduleType
}
function updateResourceLabel(resourceType: string) {
return {
APP_VERSION: '应用版本',
RN_BUNDLE: 'RN 包',
STORE_CONFIG: '应用市场',
PUBLISH_CONFIG: '发布配置',
GRAY_MEMBER: '灰度成员',
STORE_SUBMIT: '市场提交流程',
SERVICE: '服务配置',
}[resourceType] ?? resourceType
}
function updateActionLabel(action: string) {
return {
UPLOAD: '上传',
PUBLISH: '发布',
REPUBLISH: '重新发布',
SCHEDULE_PUBLISH: '定时发布',
UPDATE_FORCE: '修改强更',
UNPUBLISH: '下架',
GRAY_UPDATE: '灰度调整',
STORE_SUBMIT: '提交市场',
CREATE_STORE_CONFIG: '创建配置',
UPDATE_STORE_CONFIG: '更新配置',
DELETE_STORE_CONFIG: '删除配置',
AUTO_PUBLISH: '自动发布',
REQUEST_SERVICE_ACTIVATION: '申请开通',
UPDATE_SERVICE_CONFIG: '更新服务配置',
DISABLE_SERVICE: '停用服务',
VIEW_DASHBOARD: '查看控制台',
}[action] ?? action
}
function formatDetail(detailJson?: string) {
if (!detailJson) return '-'
try {
const parsed = JSON.parse(detailJson)
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 0)
if (parsed === null || parsed === undefined) return '-'
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed)
} catch {
return detailJson
}
@ -151,7 +293,8 @@ function formatTime(value: string | number | null | undefined) {
}
.responsive-toolbar :deep(.el-select),
.responsive-toolbar :deep(.el-button) {
.responsive-toolbar :deep(.el-button),
.responsive-toolbar :deep(.el-input-number) {
width: 100%;
}

查看文件

@ -530,6 +530,12 @@
</el-button>
</el-upload>
</el-form-item>
<el-form-item v-if="appPackageUploadProgress > 0" label="文件进度">
<div class="upload-progress-block">
<el-progress :percentage="appPackageUploadProgress" :status="appPackageUploadProgress === 100 ? 'success' : undefined" />
<span class="upload-progress-text">{{ appPackageUploadProgress }}%</span>
</div>
</el-form-item>
<el-alert
v-if="appUploadForm.platform !== 'ANDROID'"
type="info"
@ -544,6 +550,12 @@
show-icon
title="选中 APK 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前应用不一致,可选择强制继续使用。"
/>
<el-form-item v-if="appVersionUploadProgress > 0" label="提交进度">
<div class="upload-progress-block">
<el-progress :percentage="appVersionUploadProgress" :status="appVersionUploadProgress === 100 ? 'success' : undefined" />
<span class="upload-progress-text">{{ appVersionUploadProgress }}%</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadApp = false">取消</el-button>
@ -559,6 +571,12 @@
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-form-item v-if="rnInspectUploadProgress > 0" label="识别进度">
<div class="upload-progress-block">
<el-progress :percentage="rnInspectUploadProgress" :status="rnInspectUploadProgress === 100 ? 'success' : undefined" />
<span class="upload-progress-text">{{ rnInspectUploadProgress }}%</span>
</div>
</el-form-item>
<el-alert
type="info"
:closable="false"
@ -579,6 +597,12 @@
<el-input v-model="rnUploadForm.packageName" placeholder="可选,建议与应用包名一致" />
</el-form-item>
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="rnBundleUploadProgress > 0" label="提交进度">
<div class="upload-progress-block">
<el-progress :percentage="rnBundleUploadProgress" :status="rnBundleUploadProgress === 100 ? 'success' : undefined" />
<span class="upload-progress-text">{{ rnBundleUploadProgress }}%</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadRn = false">取消</el-button>
@ -649,6 +673,10 @@ const grayMemberKeyword = ref('')
const grayMemberGroupFilter = ref('')
const grayMemberIds = ref<string[]>([])
const appPackageInspecting = ref(false)
const appPackageUploadProgress = ref(0)
const appVersionUploadProgress = ref(0)
const rnInspectUploadProgress = ref(0)
const rnBundleUploadProgress = ref(0)
const operationLogs = ref<{
id: string
appId: string
@ -961,15 +989,19 @@ async function removeStoreConfig(type: StoreType) {
}
function normalizePublishConfig(raw: Record<string, unknown> | null | undefined) {
const normalizeCallbackUrl = (value: unknown) => {
const text = String(value ?? '').trim()
return /^https?:\/\//i.test(text) ? text : ''
}
const grayMode = String(raw?.grayMode ?? 'PERCENT') as GrayMode
const graySelectionSource = String(raw?.graySelectionSource ?? 'LOCAL') as GraySelectionSource
return {
defaultGrayPercent: Number((raw as Record<string, unknown>)?.defaultGrayPercent ?? 0),
grayMode,
graySelectionSource,
graySelectCallbackUrl: String((raw as Record<string, unknown>)?.graySelectCallbackUrl ?? ''),
graySelectCallbackUrl: normalizeCallbackUrl((raw as Record<string, unknown>)?.graySelectCallbackUrl),
graySelectCallbackSecret: String((raw as Record<string, unknown>)?.graySelectCallbackSecret ?? ''),
grayDirectorySyncCallbackUrl: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl ?? ''),
grayDirectorySyncCallbackUrl: normalizeCallbackUrl((raw as Record<string, unknown>)?.grayDirectorySyncCallbackUrl),
grayDirectorySyncCallbackSecret: String((raw as Record<string, unknown>)?.grayDirectorySyncCallbackSecret ?? ''),
}
}
@ -1003,9 +1035,15 @@ async function loadPublishConfig() {
async function savePublishConfig() {
savingPublishConfig.value = true
try {
const normalizeCallbackUrl = (value: string) => {
const trimmed = value.trim()
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
}
const payload = {
...publishConfigForm.value,
defaultGrayPercent: Math.min(Math.max(Number(publishConfigForm.value.defaultGrayPercent || 0), 0), 100),
graySelectCallbackUrl: normalizeCallbackUrl(publishConfigForm.value.graySelectCallbackUrl),
grayDirectorySyncCallbackUrl: normalizeCallbackUrl(publishConfigForm.value.grayDirectorySyncCallbackUrl),
}
if (payload.grayMode === 'MEMBERS' && !hasAnyGrayCallback.value) {
ElMessage.warning('成员模式至少需要配置一个回调地址')
@ -1224,8 +1262,12 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
if (!file) return
appPackageInspecting.value = true
appPackageUploadProgress.value = 0
try {
const uploaded = await fileApi.uploadFile(file)
const uploaded = await fileApi.uploadFile(file, null, (percent) => {
appPackageUploadProgress.value = percent
})
appPackageUploadProgress.value = 100
const fileInfo = uploaded.data.data
appUploadForm.value.fileUrl = fileInfo.url
const res = await updateAdminApi.inspectAppPackage(fileInfo.url)
@ -1256,6 +1298,7 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
} catch {
ElMessage.warning('已选择文件,但未能完整识别,请补全版本信息后上传')
} finally {
appPackageUploadProgress.value = 0
appPackageInspecting.value = false
}
}
@ -1266,6 +1309,7 @@ async function submitAppUpload() {
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
uploadingApp.value = true
appVersionUploadProgress.value = 0
try {
const fd = new FormData()
fd.append('appId', appId)
@ -1277,11 +1321,15 @@ async function submitAppUpload() {
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)
await updateAdminApi.uploadAppVersion(fd, (percent) => {
appVersionUploadProgress.value = percent
})
appVersionUploadProgress.value = 100
ElMessage.success('上传成功')
showUploadApp.value = false
await loadAppVersions()
} finally {
appVersionUploadProgress.value = 0
uploadingApp.value = false
}
}
@ -1332,8 +1380,12 @@ async function onRnBundleChange(uploadFile: { raw?: File } | null) {
const formData = new FormData()
formData.append('bundle', file)
rnInspectUploadProgress.value = 0
try {
const res = await updateAdminApi.inspectRnBundle(formData)
const res = await updateAdminApi.inspectRnBundle(formData, (percent) => {
rnInspectUploadProgress.value = percent
})
rnInspectUploadProgress.value = 100
const inspected = res.data.data as RnBundleInspectResult
if (inspected.moduleId) rnUploadForm.value.moduleId = inspected.moduleId
if (inspected.platform) rnUploadForm.value.platform = inspected.platform
@ -1342,6 +1394,8 @@ async function onRnBundleChange(uploadFile: { raw?: File } | null) {
if (inspected.packageName) rnUploadForm.value.packageName = inspected.packageName
} catch {
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
} finally {
rnInspectUploadProgress.value = 0
}
}
@ -1349,6 +1403,7 @@ async function submitRnUpload() {
const f = rnUploadForm.value
if (!f.moduleId || !f.version || !f.file) return ElMessage.warning('请填写模块ID、版本和 Bundle 文件')
uploadingRn.value = true
rnBundleUploadProgress.value = 0
try {
const fd = new FormData()
fd.append('appId', appId)
@ -1359,11 +1414,15 @@ async function submitRnUpload() {
if (f.packageName) fd.append('packageName', f.packageName)
if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd)
await updateAdminApi.uploadRnBundle(fd, (percent) => {
rnBundleUploadProgress.value = percent
})
rnBundleUploadProgress.value = 100
ElMessage.success('Bundle 上传成功')
showUploadRn.value = false
await loadRnBundles()
} finally {
rnBundleUploadProgress.value = 0
uploadingRn.value = false
}
}
@ -1760,6 +1819,18 @@ onBeforeUnmount(() => {
color: var(--el-text-color-secondary);
}
.upload-progress-block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.upload-progress-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.release-store-checkbox-row {
display: flex;
align-items: center;

查看文件

@ -43,7 +43,7 @@ export default defineConfig(({ mode }) => {
port: 5173,
proxy: {
'/api': {
target: 'http://192.168.116.9:8081',
target: 'http://127.0.0.1:8081',
changeOrigin: true,
},
},