feat: refine ops package workflow and upload handling
这个提交包含在:
父节点
0314acc18e
当前提交
78e362eb22
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.pnp.*
|
||||
*.tsbuildinfo
|
||||
*.iml
|
||||
*.log
|
||||
AndroidLibs/.gradle-home/
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
package com.xuqm.sdk.compose.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AccordionGroup(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
initiallyExpanded: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
|
||||
Card(modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(expanded) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package com.xuqm.sdk.compose.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@ -24,7 +26,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.xuqm.sdk.CoreSDK
|
||||
import com.xuqm.sdk.compose.components.FeatureCard
|
||||
import com.xuqm.szyx.SzyxSDK
|
||||
import com.xuqm.szyx.auth.LoginSession
|
||||
import com.xuqm.szyx.auth.UserSessionManager
|
||||
@ -97,14 +98,14 @@ private fun PluginUiScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
FeatureCard(
|
||||
PluginInfoCard(
|
||||
title = "插件独立运行",
|
||||
description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
FeatureCard(
|
||||
PluginInfoCard(
|
||||
title = "共享缓存",
|
||||
description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。",
|
||||
)
|
||||
@ -112,3 +113,23 @@ private fun PluginUiScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginInfoCard(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -40,8 +41,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.xuqm.sdk.CoreSDK
|
||||
import com.xuqm.sdk.compose.components.AccordionGroup
|
||||
import com.xuqm.sdk.compose.components.FeatureCard
|
||||
import com.xuqm.sdk.compose.components.accordion.AccordionPanel
|
||||
import com.xuqm.sdk.plugin.PluginPackageManager
|
||||
import com.xuqm.sdk.ui.ToastCenter
|
||||
import com.xuqm.sdk.update.DownloadState
|
||||
@ -485,7 +485,7 @@ private fun SampleHome(
|
||||
}
|
||||
|
||||
item {
|
||||
AccordionGroup(title = "当前方案", initiallyExpanded = true) {
|
||||
AccordionPanel(title = "当前方案", initiallyExpanded = true) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("1. 未登录启动时直接进入 lib-szyx 登录页")
|
||||
Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度")
|
||||
@ -495,7 +495,7 @@ private fun SampleHome(
|
||||
}
|
||||
|
||||
item {
|
||||
FeatureCard(
|
||||
InfoCard(
|
||||
title = "插件结构",
|
||||
description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。",
|
||||
)
|
||||
@ -504,6 +504,26 @@ private fun SampleHome(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DownloadState.toDisplayText(): String {
|
||||
return when (this) {
|
||||
DownloadState.Idle -> "未开始"
|
||||
|
||||
@ -48,6 +48,7 @@ export interface ReleaseRecord {
|
||||
entryActivity?: string | null
|
||||
minHostVersionCode?: number | null
|
||||
minHostVersionName?: string | null
|
||||
forceUpdate?: boolean
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE'
|
||||
publishStrategy: string
|
||||
grayRule?: {
|
||||
@ -100,9 +101,10 @@ export interface QuickSelection {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const isFormData = typeof FormData !== 'undefined' && init?.body instanceof FormData
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
@ -163,6 +165,20 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
uploadAppArtifact(appId: string, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request<{
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
uploadedFileName: string
|
||||
downloadUrl: string
|
||||
}>(`/api/v1/ops/apps/${appId}/packages/artifact`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
listPlugins(appId: string) {
|
||||
return request<PluginConfig[]>(`/api/v1/ops/apps/${appId}/plugins`)
|
||||
},
|
||||
@ -187,12 +203,37 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
uploadPluginArtifact(pluginId: string, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request<{
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
uploadedFileName: string
|
||||
downloadUrl: string
|
||||
}>(`/api/v1/ops/plugins/${pluginId}/packages/artifact`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
publishPackage(releaseId: string, payload: Record<string, unknown>) {
|
||||
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
updatePackage(releaseId: string, payload: Record<string, unknown>) {
|
||||
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
deletePackage(releaseId: string) {
|
||||
return request<void>(`/api/v1/ops/packages/${releaseId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
listAudienceGroups() {
|
||||
return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups')
|
||||
},
|
||||
|
||||
@ -37,10 +37,14 @@ select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@ -162,6 +166,11 @@ button {
|
||||
color: #163454;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: #fff1f1;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border-top: 1px solid rgba(16, 35, 61, 0.08);
|
||||
margin-top: 20px;
|
||||
@ -200,6 +209,11 @@ button {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wide-tabs button[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sub-panel {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
@ -248,6 +262,26 @@ button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(7, 19, 37, 0.38);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(960px, 100%);
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 24px 60px rgba(15, 39, 71, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell,
|
||||
.stack {
|
||||
@ -256,5 +290,7 @@ button {
|
||||
|
||||
.sidebar {
|
||||
gap: 24px;
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,147 +1,88 @@
|
||||
<template>
|
||||
<section class="page stack">
|
||||
<section class="page">
|
||||
<div class="panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">App 管理</p>
|
||||
<h2>应用列表</h2>
|
||||
<h2>{{ selectedApp ? selectedApp.application.name : '请选择应用' }}</h2>
|
||||
<p class="muted">
|
||||
{{ selectedApp ? selectedApp.application.packageName : '点击左侧 App 管理后,先从弹窗中选择一个应用。' }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="primary" @click="showCreateApp = !showCreateApp">创建 App</button>
|
||||
</div>
|
||||
|
||||
<form v-if="showCreateApp" class="form-grid" @submit.prevent="createApp">
|
||||
<label><span>名称</span><input v-model="appForm.name" required /></label>
|
||||
<label><span>包名</span><input v-model="appForm.packageName" required /></label>
|
||||
<label><span>插件包名前缀</span><input v-model="appForm.pluginPackageName" /></label>
|
||||
<label class="full"><span>说明</span><input v-model="appForm.description" /></label>
|
||||
<label class="toggle"><input type="checkbox" v-model="appForm.pluginManagementEnabled" />支持插件化</label>
|
||||
<button class="primary">保存 App</button>
|
||||
</form>
|
||||
|
||||
<div class="list-grid">
|
||||
<button
|
||||
v-for="item in applications"
|
||||
:key="item.application.id"
|
||||
class="list-card"
|
||||
:data-active="selectedApp?.application.id === item.application.id"
|
||||
@click="selectApp(item)"
|
||||
>
|
||||
<strong>{{ item.application.name }}</strong>
|
||||
<span>{{ item.application.packageName }}</span>
|
||||
<span>{{ item.application.pluginManagementEnabled ? '支持插件化' : '仅宿主' }}</span>
|
||||
<div class="actions">
|
||||
<button class="secondary" @click="openSelector">切换应用</button>
|
||||
<button v-if="selectedApp" class="primary" @click="openCreateModal">
|
||||
{{ tab === 'packages' ? '添加 APK' : '添加插件' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" v-if="selectedApp">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">当前应用</p>
|
||||
<h2>{{ selectedApp.application.name }}</h2>
|
||||
<template v-if="selectedApp">
|
||||
<div class="selected-bar">
|
||||
<div class="chips">
|
||||
<span>{{ selectedApp.application.pluginManagementEnabled ? '支持插件化' : '仅宿主 APK' }}</span>
|
||||
<span>{{ selectedApp.application.businessModules.join(' / ') }}</span>
|
||||
</div>
|
||||
<button class="secondary" @click="loadAll">刷新</button>
|
||||
<button class="secondary" @click="loadSelectedApp">刷新当前应用</button>
|
||||
</div>
|
||||
|
||||
<form class="form-grid" @submit.prevent="saveApp">
|
||||
<label><span>名称</span><input v-model="appEdit.name" required /></label>
|
||||
<label><span>包名</span><input v-model="appEdit.packageName" required /></label>
|
||||
<label><span>插件包名前缀</span><input v-model="appEdit.pluginPackageName" /></label>
|
||||
<label class="full"><span>说明</span><input v-model="appEdit.description" /></label>
|
||||
<label class="toggle"><input type="checkbox" v-model="appEdit.pluginManagementEnabled" />支持插件化</label>
|
||||
<button class="primary">修改 App 信息</button>
|
||||
</form>
|
||||
|
||||
<div class="tab-row">
|
||||
<button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">安装包</button>
|
||||
<div class="tab-row wide-tabs">
|
||||
<button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">APK 列表</button>
|
||||
<button
|
||||
v-if="selectedApp.application.pluginManagementEnabled"
|
||||
:class="tab === 'plugins' ? 'primary' : 'secondary'"
|
||||
:disabled="!selectedApp.application.pluginManagementEnabled"
|
||||
@click="tab = 'plugins'"
|
||||
>
|
||||
插件列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'packages'">
|
||||
<form class="form-grid" @submit.prevent="uploadAppPackage">
|
||||
<label><span>包名</span><input v-model="appPackageForm.packageName" required /></label>
|
||||
<label><span>版本名</span><input v-model="appPackageForm.versionName" required /></label>
|
||||
<label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" required /></label>
|
||||
<label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" required /></label>
|
||||
<label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" required /></label>
|
||||
<label class="full"><span>标题</span><input v-model="appPackageForm.title" required /></label>
|
||||
<label class="full"><span>更新说明</span><textarea v-model="appPackageForm.changelog" rows="3" /></label>
|
||||
<button class="primary">上传安装包</button>
|
||||
</form>
|
||||
<div class="sub-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">{{ tab === 'packages' ? 'APK 管理' : '插件管理' }}</p>
|
||||
<h3>{{ tab === 'packages' ? 'APK 安装包列表' : '插件安装包列表' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>版本</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<thead v-if="tab === 'packages'">
|
||||
<tr><th>版本</th><th>文件名</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<thead v-else>
|
||||
<tr><th>插件</th><th>版本</th><th>宿主最低版本</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody v-if="tab === 'packages'">
|
||||
<tr v-for="release in appPackages" :key="release.id">
|
||||
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
||||
<td>{{ release.uploadedFileName }}</td>
|
||||
<td>{{ release.status }}</td>
|
||||
<td class="actions">
|
||||
<button class="ghost" @click="publishFull(release.id)">发布当前安装包</button>
|
||||
<button class="ghost" @click="prepareGray(release.id)">配置灰度</button>
|
||||
<button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
|
||||
<button class="ghost" @click="publishFull(release.id)">发布</button>
|
||||
<button class="ghost" @click="prepareGray(release.id)">灰度</button>
|
||||
<button class="ghost" @click="openEditReleaseModal(release, 'APP')">编辑</button>
|
||||
<button v-if="release.status === 'DRAFT'" class="ghost danger" @click="removeRelease(release.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<form class="form-grid" @submit.prevent="createPlugin">
|
||||
<label><span>插件名</span><input v-model="pluginForm.name" required /></label>
|
||||
<label><span>插件包名</span><input v-model="pluginForm.packageName" required /></label>
|
||||
<label class="full"><span>入口 Activity</span><input v-model="pluginForm.entryActivity" /></label>
|
||||
<label class="full"><span>说明</span><input v-model="pluginForm.description" /></label>
|
||||
<button class="primary">新建插件</button>
|
||||
</form>
|
||||
|
||||
<div class="list-grid">
|
||||
<button
|
||||
v-for="plugin in plugins"
|
||||
:key="plugin.id"
|
||||
class="list-card"
|
||||
:data-active="selectedPlugin?.id === plugin.id"
|
||||
@click="selectPlugin(plugin)"
|
||||
>
|
||||
<strong>{{ plugin.name }}</strong>
|
||||
<span>{{ plugin.packageName }}</span>
|
||||
<span>{{ plugin.enabled ? '已启用' : '未启用' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPlugin" class="sub-panel">
|
||||
<h3>{{ selectedPlugin.name }} 安装包</h3>
|
||||
<form class="form-grid" @submit.prevent="uploadPluginPackage">
|
||||
<label><span>版本名</span><input v-model="pluginPackageForm.versionName" required /></label>
|
||||
<label><span>版本码</span><input v-model.number="pluginPackageForm.versionCode" type="number" required /></label>
|
||||
<label><span>宿主最低版本码</span><input v-model.number="pluginPackageForm.minHostVersionCode" type="number" /></label>
|
||||
<label><span>宿主最低版本名</span><input v-model="pluginPackageForm.minHostVersionName" /></label>
|
||||
<label><span>文件名</span><input v-model="pluginPackageForm.uploadedFileName" required /></label>
|
||||
<label class="full"><span>下载地址</span><input v-model="pluginPackageForm.downloadUrl" required /></label>
|
||||
<label class="full"><span>标题</span><input v-model="pluginPackageForm.title" required /></label>
|
||||
<label class="full"><span>更新说明</span><textarea v-model="pluginPackageForm.changelog" rows="3" /></label>
|
||||
<button class="primary">上传插件安装包</button>
|
||||
</form>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>版本</th><th>宿主最低版本</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="release in pluginPackages" :key="release.id">
|
||||
<tbody v-else>
|
||||
<tr v-for="release in pluginReleaseRows" :key="release.id">
|
||||
<td>{{ release.pluginName }}</td>
|
||||
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
||||
<td>{{ release.minHostVersionName || '-' }}</td>
|
||||
<td>{{ release.status }}</td>
|
||||
<td class="actions">
|
||||
<button class="ghost" @click="publishFull(release.id)">发版</button>
|
||||
<button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
|
||||
<button class="ghost" @click="publishFull(release.id)">发布</button>
|
||||
<button class="ghost" @click="prepareGray(release.id)">灰度</button>
|
||||
<button class="ghost" @click="openEditReleaseModal(release, 'PLUGIN')">编辑</button>
|
||||
<button v-if="release.status === 'DRAFT'" class="ghost danger" @click="removeRelease(release.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="grayReleaseId" class="sub-panel">
|
||||
<h3>灰度信息配置</h3>
|
||||
@ -176,94 +117,484 @@
|
||||
<button class="primary" @click="publishGray">确认灰度发布</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="selectorOpen" class="modal-mask" @click.self="selectorOpen = false">
|
||||
<div class="modal-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">应用选择</p>
|
||||
<h3>App 列表</h3>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" @click="openCreateAppModal">创建应用</button>
|
||||
<button class="secondary" @click="selectorOpen = false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-grid">
|
||||
<div
|
||||
v-for="item in applications"
|
||||
:key="item.application.id"
|
||||
class="list-card"
|
||||
:data-active="selectedApp?.application.id === item.application.id"
|
||||
>
|
||||
<strong>{{ item.application.name }}</strong>
|
||||
<span>{{ item.application.packageName }}</span>
|
||||
<span>{{ item.application.pluginManagementEnabled ? '支持插件化' : '仅宿主' }}</span>
|
||||
<div class="actions">
|
||||
<button class="ghost" @click="selectApp(item)">进入</button>
|
||||
<button class="ghost" @click="openEditAppModal(item)">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appModalOpen" class="modal-mask" @click.self="closeAppModal">
|
||||
<div class="modal-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">应用设置</p>
|
||||
<h3>{{ appModalMode === 'create' ? '创建应用' : '编辑应用' }}</h3>
|
||||
</div>
|
||||
<button class="secondary" @click="closeAppModal">关闭</button>
|
||||
</div>
|
||||
|
||||
<form class="form-grid" @submit.prevent="submitAppModal">
|
||||
<label><span>名称</span><input v-model="appForm.name" required /></label>
|
||||
<label><span>包名</span><input v-model="appForm.packageName" required /></label>
|
||||
<label><span>插件包名前缀</span><input v-model="appForm.pluginPackageName" /></label>
|
||||
<label class="full"><span>说明</span><input v-model="appForm.description" /></label>
|
||||
<label class="toggle"><input type="checkbox" v-model="appForm.pluginManagementEnabled" />支持插件化</label>
|
||||
<button class="primary">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="releaseModalOpen" class="modal-mask" @click.self="closeReleaseModal">
|
||||
<div class="modal-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">{{ releaseModalType === 'APP' ? 'APK' : '插件' }}</p>
|
||||
<h3>{{ releaseModalMode === 'create' ? '新增安装包' : '编辑安装包' }}</h3>
|
||||
</div>
|
||||
<button class="secondary" @click="closeReleaseModal">关闭</button>
|
||||
</div>
|
||||
|
||||
<template v-if="releaseModalType === 'APP'">
|
||||
<form class="form-grid" @submit.prevent="submitAppRelease">
|
||||
<label v-if="releaseModalMode === 'create'" class="full">
|
||||
<span>安装包文件</span>
|
||||
<input type="file" accept=".apk" @change="onAppFileSelected" />
|
||||
</label>
|
||||
<label><span>版本名</span><input v-model="appPackageForm.versionName" readonly /></label>
|
||||
<label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" readonly /></label>
|
||||
<label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" readonly /></label>
|
||||
<label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" readonly /></label>
|
||||
<label class="full"><span>标题</span><input v-model="appPackageForm.title" required /></label>
|
||||
<label class="full"><span>更新说明</span><textarea v-model="appPackageForm.changelog" rows="3" /></label>
|
||||
<button class="primary" :disabled="uploadingArtifact">{{ uploadingArtifact ? '上传中...' : '保存' }}</button>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="releaseModalMode === 'create'" class="tab-row">
|
||||
<button type="button" :class="pluginCreateMode === 'plugin' ? 'primary' : 'secondary'" @click="pluginCreateMode = 'plugin'">
|
||||
新建插件
|
||||
</button>
|
||||
<button type="button" :class="pluginCreateMode === 'package' ? 'primary' : 'secondary'" @click="pluginCreateMode = 'package'">
|
||||
上传插件包
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="releaseModalMode === 'create' && pluginCreateMode === 'plugin'" class="form-grid" @submit.prevent="submitCreatePlugin">
|
||||
<label><span>插件名</span><input v-model="pluginForm.name" required /></label>
|
||||
<label><span>插件包名</span><input v-model="pluginForm.packageName" required /></label>
|
||||
<label class="full"><span>入口 Activity</span><input v-model="pluginForm.entryActivity" /></label>
|
||||
<label class="full"><span>说明</span><input v-model="pluginForm.description" /></label>
|
||||
<button class="primary">保存插件</button>
|
||||
</form>
|
||||
|
||||
<form v-else class="form-grid" @submit.prevent="submitPluginRelease">
|
||||
<label v-if="releaseModalMode === 'create'">
|
||||
<span>插件</span>
|
||||
<select v-model="pluginReleaseForm.pluginId" required>
|
||||
<option value="">请选择插件</option>
|
||||
<option v-for="plugin in plugins" :key="plugin.id" :value="plugin.id">{{ plugin.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="releaseModalMode === 'create'" class="full">
|
||||
<span>安装包文件</span>
|
||||
<input type="file" accept=".apk" @change="onPluginFileSelected" />
|
||||
</label>
|
||||
<label><span>版本名</span><input v-model="pluginReleaseForm.versionName" readonly /></label>
|
||||
<label><span>版本码</span><input v-model.number="pluginReleaseForm.versionCode" type="number" readonly /></label>
|
||||
<label><span>宿主最低版本码</span><input v-model.number="pluginReleaseForm.minHostVersionCode" type="number" /></label>
|
||||
<label><span>宿主最低版本名</span><input v-model="pluginReleaseForm.minHostVersionName" /></label>
|
||||
<label><span>文件名</span><input v-model="pluginReleaseForm.uploadedFileName" readonly /></label>
|
||||
<label class="full"><span>下载地址</span><input v-model="pluginReleaseForm.downloadUrl" readonly /></label>
|
||||
<label class="full"><span>标题</span><input v-model="pluginReleaseForm.title" required /></label>
|
||||
<label class="full"><span>更新说明</span><textarea v-model="pluginReleaseForm.changelog" rows="3" /></label>
|
||||
<button class="primary" :disabled="uploadingArtifact">{{ uploadingArtifact ? '上传中...' : '保存' }}</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type PluginConfig, type QuickSelection, type ReleaseRecord } from '../api/client'
|
||||
|
||||
type ReleaseModalType = 'APP' | 'PLUGIN'
|
||||
type ModalMode = 'create' | 'edit'
|
||||
type PluginReleaseRow = ReleaseRecord & { pluginName: string }
|
||||
|
||||
const STORAGE_KEY = 'ops-selected-app-id'
|
||||
|
||||
const applications = ref<ApplicationDetail[]>([])
|
||||
const selectedApp = ref<ApplicationDetail | null>(null)
|
||||
const selectedPlugin = ref<PluginConfig | null>(null)
|
||||
const appPackages = ref<ReleaseRecord[]>([])
|
||||
const pluginPackages = ref<ReleaseRecord[]>([])
|
||||
const plugins = ref<PluginConfig[]>([])
|
||||
const pluginReleaseRows = ref<PluginReleaseRow[]>([])
|
||||
const users = ref<AudienceUser[]>([])
|
||||
const groups = ref<AudienceGroup[]>([])
|
||||
const quickSelections = ref<QuickSelection[]>([])
|
||||
const selectedUsers = ref<string[]>([])
|
||||
const grayReleaseId = ref('')
|
||||
const showCreateApp = ref(false)
|
||||
const uploadingArtifact = ref(false)
|
||||
const selectorOpen = ref(false)
|
||||
const appModalOpen = ref(false)
|
||||
const releaseModalOpen = ref(false)
|
||||
const appModalMode = ref<ModalMode>('create')
|
||||
const releaseModalMode = ref<ModalMode>('create')
|
||||
const releaseModalType = ref<ReleaseModalType>('APP')
|
||||
const pluginCreateMode = ref<'plugin' | 'package'>('package')
|
||||
const editingAppId = ref('')
|
||||
const editingReleaseId = ref('')
|
||||
const tab = ref<'packages' | 'plugins'>('packages')
|
||||
|
||||
const filters = reactive({ keyword: '', groupCode: '', quickSelectionCode: '' })
|
||||
const appForm = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] })
|
||||
const appEdit = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] })
|
||||
const pluginForm = reactive({ name: '', packageName: '', entryActivity: '', description: '' })
|
||||
const appPackageForm = reactive({ packageName: '', versionCode: 1, versionName: '', title: '发现新版本', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', forceUpdate: false })
|
||||
const pluginPackageForm = reactive({ versionCode: 1, versionName: '', title: '插件更新', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', minHostVersionCode: 0, minHostVersionName: '' })
|
||||
const pluginReleaseForm = reactive({ pluginId: '', versionCode: 1, versionName: '', title: '插件更新', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', minHostVersionCode: 0, minHostVersionName: '' })
|
||||
|
||||
const pluginOptions = computed(() => plugins.value.map(item => ({ label: item.name, value: item.id })))
|
||||
|
||||
function resetAppForm() {
|
||||
Object.assign(appForm, {
|
||||
name: '',
|
||||
packageName: '',
|
||||
pluginPackageName: '',
|
||||
description: '',
|
||||
pluginManagementEnabled: true,
|
||||
businessModules: ['IM', 'PUSH', 'VERSION'],
|
||||
})
|
||||
}
|
||||
|
||||
function resetAppPackageForm() {
|
||||
Object.assign(appPackageForm, {
|
||||
packageName: selectedApp.value?.application.packageName ?? '',
|
||||
versionCode: 1,
|
||||
versionName: '',
|
||||
title: '有新版本待更新',
|
||||
changelog: '',
|
||||
downloadUrl: '',
|
||||
uploadedFileName: '',
|
||||
entryActivity: '',
|
||||
forceUpdate: false,
|
||||
})
|
||||
}
|
||||
|
||||
function resetPluginForm() {
|
||||
Object.assign(pluginForm, {
|
||||
name: '',
|
||||
packageName: '',
|
||||
entryActivity: '',
|
||||
description: '',
|
||||
})
|
||||
}
|
||||
|
||||
function resetPluginReleaseForm() {
|
||||
Object.assign(pluginReleaseForm, {
|
||||
pluginId: pluginOptions.value[0]?.value ?? '',
|
||||
versionCode: 1,
|
||||
versionName: '',
|
||||
title: '有新版本待更新',
|
||||
changelog: '',
|
||||
downloadUrl: '',
|
||||
uploadedFileName: '',
|
||||
entryActivity: '',
|
||||
minHostVersionCode: 0,
|
||||
minHostVersionName: '',
|
||||
})
|
||||
}
|
||||
|
||||
function persistSelectedApp(appId: string) {
|
||||
localStorage.setItem(STORAGE_KEY, appId)
|
||||
}
|
||||
|
||||
function loadStoredAppId() {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function openSelector() {
|
||||
selectorOpen.value = true
|
||||
}
|
||||
|
||||
function openCreateAppModal() {
|
||||
appModalMode.value = 'create'
|
||||
editingAppId.value = ''
|
||||
resetAppForm()
|
||||
appModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditAppModal(item: ApplicationDetail) {
|
||||
appModalMode.value = 'edit'
|
||||
editingAppId.value = item.application.id
|
||||
Object.assign(appForm, item.application)
|
||||
appModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeAppModal() {
|
||||
appModalOpen.value = false
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
releaseModalMode.value = 'create'
|
||||
editingReleaseId.value = ''
|
||||
releaseModalType.value = tab.value === 'packages' ? 'APP' : 'PLUGIN'
|
||||
if (releaseModalType.value === 'APP') {
|
||||
resetAppPackageForm()
|
||||
} else {
|
||||
pluginCreateMode.value = plugins.value.length > 0 ? 'package' : 'plugin'
|
||||
resetPluginForm()
|
||||
resetPluginReleaseForm()
|
||||
}
|
||||
releaseModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditReleaseModal(release: ReleaseRecord, type: ReleaseModalType) {
|
||||
releaseModalMode.value = 'edit'
|
||||
releaseModalType.value = type
|
||||
editingReleaseId.value = release.id
|
||||
if (type === 'APP') {
|
||||
Object.assign(appPackageForm, {
|
||||
packageName: release.packageName,
|
||||
versionCode: release.versionCode,
|
||||
versionName: release.versionName,
|
||||
title: release.title,
|
||||
changelog: release.changelog,
|
||||
downloadUrl: release.downloadUrl,
|
||||
uploadedFileName: release.uploadedFileName,
|
||||
entryActivity: release.entryActivity ?? '',
|
||||
forceUpdate: release.forceUpdate ?? false,
|
||||
})
|
||||
} else {
|
||||
Object.assign(pluginReleaseForm, {
|
||||
pluginId: release.pluginId ?? '',
|
||||
versionCode: release.versionCode,
|
||||
versionName: release.versionName,
|
||||
title: release.title,
|
||||
changelog: release.changelog,
|
||||
downloadUrl: release.downloadUrl,
|
||||
uploadedFileName: release.uploadedFileName,
|
||||
entryActivity: release.entryActivity ?? '',
|
||||
minHostVersionCode: release.minHostVersionCode ?? 0,
|
||||
minHostVersionName: release.minHostVersionName ?? '',
|
||||
})
|
||||
}
|
||||
releaseModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeReleaseModal() {
|
||||
releaseModalOpen.value = false
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
applications.value = await api.listApplications()
|
||||
const [groupList, quickList] = await Promise.all([api.listAudienceGroups(), api.listQuickSelections()])
|
||||
groups.value = groupList
|
||||
quickSelections.value = quickList
|
||||
if (applications.value.length > 0 && !selectedApp.value) {
|
||||
await selectApp(applications.value[0])
|
||||
|
||||
const storedAppId = loadStoredAppId()
|
||||
const matched = applications.value.find(item => item.application.id === storedAppId) ?? null
|
||||
if (matched) {
|
||||
await selectApp(matched, false)
|
||||
} else {
|
||||
selectorOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function selectApp(item: ApplicationDetail) {
|
||||
async function loadSelectedApp() {
|
||||
if (!selectedApp.value) {
|
||||
selectorOpen.value = true
|
||||
return
|
||||
}
|
||||
const latest = await api.listApplications()
|
||||
applications.value = latest
|
||||
const matched = latest.find(item => item.application.id === selectedApp.value?.application.id)
|
||||
if (matched) {
|
||||
await selectApp(matched, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPluginReleaseRows() {
|
||||
const packageGroups = await Promise.all(
|
||||
plugins.value.map(async plugin => {
|
||||
const releases = await api.listPluginPackages(plugin.id)
|
||||
return releases.map(release => ({ ...release, pluginName: plugin.name }))
|
||||
}),
|
||||
)
|
||||
pluginReleaseRows.value = packageGroups.flat().sort((a, b) => b.versionCode - a.versionCode)
|
||||
}
|
||||
|
||||
async function selectApp(item: ApplicationDetail, closeModal = true) {
|
||||
selectedApp.value = item
|
||||
selectedPlugin.value = null
|
||||
selectedUsers.value = []
|
||||
grayReleaseId.value = ''
|
||||
tab.value = 'packages'
|
||||
Object.assign(appEdit, item.application)
|
||||
appPackageForm.packageName = item.application.packageName
|
||||
persistSelectedApp(item.application.id)
|
||||
appPackages.value = await api.listAppPackages(item.application.id)
|
||||
plugins.value = await api.listPlugins(item.application.id)
|
||||
await refreshPluginReleaseRows()
|
||||
if (closeModal) {
|
||||
selectorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
async function submitAppModal() {
|
||||
if (appModalMode.value === 'create') {
|
||||
await api.createApplication(appForm)
|
||||
showCreateApp.value = false
|
||||
} else {
|
||||
await api.updateApplication(editingAppId.value, appForm)
|
||||
}
|
||||
closeAppModal()
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
async function saveApp() {
|
||||
async function submitAppRelease() {
|
||||
if (!selectedApp.value) return
|
||||
await api.updateApplication(selectedApp.value.application.id, appEdit)
|
||||
await loadAll()
|
||||
if (!appPackageForm.downloadUrl) {
|
||||
window.alert('请先选择并上传 APK 文件')
|
||||
return
|
||||
}
|
||||
|
||||
async function uploadAppPackage() {
|
||||
if (!selectedApp.value) return
|
||||
if (releaseModalMode.value === 'create') {
|
||||
await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm)
|
||||
} else {
|
||||
await api.updatePackage(editingReleaseId.value, {
|
||||
versionCode: appPackageForm.versionCode,
|
||||
versionName: appPackageForm.versionName,
|
||||
title: appPackageForm.title,
|
||||
changelog: appPackageForm.changelog,
|
||||
downloadUrl: appPackageForm.downloadUrl,
|
||||
uploadedFileName: appPackageForm.uploadedFileName,
|
||||
entryActivity: appPackageForm.entryActivity,
|
||||
forceUpdate: appPackageForm.forceUpdate,
|
||||
})
|
||||
}
|
||||
closeReleaseModal()
|
||||
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||
}
|
||||
|
||||
async function createPlugin() {
|
||||
async function submitPluginRelease() {
|
||||
if (!selectedApp.value) return
|
||||
if (!pluginReleaseForm.downloadUrl) {
|
||||
window.alert('请先选择并上传 APK 文件')
|
||||
return
|
||||
}
|
||||
if (releaseModalMode.value === 'create') {
|
||||
await api.uploadPluginPackage(pluginReleaseForm.pluginId, pluginReleaseForm)
|
||||
} else {
|
||||
await api.updatePackage(editingReleaseId.value, {
|
||||
packageName: '',
|
||||
versionCode: pluginReleaseForm.versionCode,
|
||||
versionName: pluginReleaseForm.versionName,
|
||||
title: pluginReleaseForm.title,
|
||||
changelog: pluginReleaseForm.changelog,
|
||||
downloadUrl: pluginReleaseForm.downloadUrl,
|
||||
uploadedFileName: pluginReleaseForm.uploadedFileName,
|
||||
entryActivity: pluginReleaseForm.entryActivity,
|
||||
minHostVersionCode: pluginReleaseForm.minHostVersionCode,
|
||||
minHostVersionName: pluginReleaseForm.minHostVersionName,
|
||||
forceUpdate: false,
|
||||
})
|
||||
}
|
||||
closeReleaseModal()
|
||||
plugins.value = await api.listPlugins(selectedApp.value.application.id)
|
||||
await refreshPluginReleaseRows()
|
||||
}
|
||||
|
||||
async function submitCreatePlugin() {
|
||||
if (!selectedApp.value) return
|
||||
await api.createPlugin(selectedApp.value.application.id, pluginForm)
|
||||
plugins.value = await api.listPlugins(selectedApp.value.application.id)
|
||||
pluginCreateMode.value = 'package'
|
||||
resetPluginForm()
|
||||
resetPluginReleaseForm()
|
||||
}
|
||||
|
||||
async function selectPlugin(plugin: PluginConfig) {
|
||||
selectedPlugin.value = plugin
|
||||
pluginPackages.value = await api.listPluginPackages(plugin.id)
|
||||
async function onAppFileSelected(event: Event) {
|
||||
if (!selectedApp.value) return
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
uploadingArtifact.value = true
|
||||
const artifact = await api.uploadAppArtifact(selectedApp.value.application.id, file)
|
||||
appPackageForm.packageName = artifact.packageName
|
||||
appPackageForm.versionCode = artifact.versionCode
|
||||
appPackageForm.versionName = artifact.versionName
|
||||
appPackageForm.uploadedFileName = artifact.uploadedFileName
|
||||
appPackageForm.downloadUrl = artifact.downloadUrl
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : 'APK 上传失败')
|
||||
input.value = ''
|
||||
} finally {
|
||||
uploadingArtifact.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPluginPackage() {
|
||||
if (!selectedPlugin.value) return
|
||||
await api.uploadPluginPackage(selectedPlugin.value.id, pluginPackageForm)
|
||||
pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
|
||||
async function onPluginFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const pluginId = pluginReleaseForm.pluginId || (releaseModalMode.value === 'edit' ? pluginReleaseForm.pluginId : '')
|
||||
if (!pluginId) {
|
||||
window.alert('请先选择插件,再上传安装包')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
uploadingArtifact.value = true
|
||||
const artifact = await api.uploadPluginArtifact(pluginId, file)
|
||||
pluginReleaseForm.versionCode = artifact.versionCode
|
||||
pluginReleaseForm.versionName = artifact.versionName
|
||||
pluginReleaseForm.uploadedFileName = artifact.uploadedFileName
|
||||
pluginReleaseForm.downloadUrl = artifact.downloadUrl
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : '插件包上传失败')
|
||||
input.value = ''
|
||||
} finally {
|
||||
uploadingArtifact.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(url: string) {
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function publishFull(releaseId: string) {
|
||||
await api.publishPackage(releaseId, { grayPublish: false })
|
||||
if (selectedApp.value) appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||
if (selectedPlugin.value) pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
|
||||
if (!selectedApp.value) return
|
||||
if (tab.value === 'packages') {
|
||||
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||
} else {
|
||||
await refreshPluginReleaseRows()
|
||||
}
|
||||
}
|
||||
|
||||
function prepareGray(releaseId: string) {
|
||||
@ -291,6 +622,22 @@ async function publishGray() {
|
||||
userIds: selectedUsers.value,
|
||||
})
|
||||
grayReleaseId.value = ''
|
||||
if (tab.value === 'packages' && selectedApp.value) {
|
||||
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||
} else {
|
||||
await refreshPluginReleaseRows()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRelease(releaseId: string) {
|
||||
const confirmed = window.confirm('删除后不可恢复,确认删除当前安装包吗?')
|
||||
if (!confirmed || !selectedApp.value) return
|
||||
await api.deletePackage(releaseId)
|
||||
if (tab.value === 'packages') {
|
||||
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||
} else {
|
||||
await refreshPluginReleaseRows()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadAll() })
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="panel">
|
||||
<p class="section-tag">开放注册</p>
|
||||
<h2>运营平台主账户注册</h2>
|
||||
<p class="muted">注册后默认进入待审核状态,管理平台可统一审核与禁用。</p>
|
||||
<form class="form-grid" @submit.prevent="submit">
|
||||
<label>
|
||||
企业名称
|
||||
<input v-model="form.accountName" required placeholder="例如:星云运营中心" />
|
||||
</label>
|
||||
<label>
|
||||
联系人
|
||||
<input v-model="form.contactName" required placeholder="请输入联系人" />
|
||||
</label>
|
||||
<label>
|
||||
邮箱
|
||||
<input v-model="form.email" required type="email" placeholder="ops@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
手机号
|
||||
<input v-model="form.phone" required placeholder="13800138000" />
|
||||
</label>
|
||||
<button class="primary" :disabled="loading">{{ loading ? '提交中...' : '提交注册' }}</button>
|
||||
</form>
|
||||
<p v-if="message" class="success-text">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel soft">
|
||||
<p class="section-tag">账号规则</p>
|
||||
<ul class="plain-list">
|
||||
<li>主账户由运营方自行注册。</li>
|
||||
<li>子账户由主账户创建并赋权,权限按业务动作拆分。</li>
|
||||
<li>版本发布、插件化开关、灰度发布都建议只给受控子账户。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const form = reactive({
|
||||
accountName: '',
|
||||
contactName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
const result = await api.registerAccount(form)
|
||||
message.value = `注册成功:${result.accountName}(状态:${result.status})`
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '注册失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,293 +0,0 @@
|
||||
<template>
|
||||
<section class="page stack">
|
||||
<div class="panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">业务模块</p>
|
||||
<h2>版本管理</h2>
|
||||
</div>
|
||||
<button class="secondary" @click="loadAll">刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-for="item in applications" :key="item.application.id" class="app-card">
|
||||
<div class="app-card__top">
|
||||
<div>
|
||||
<h3>{{ item.application.name }}</h3>
|
||||
<p class="muted">{{ item.application.packageName }}</p>
|
||||
<p class="chips">
|
||||
<span v-for="module in item.application.businessModules" :key="module">{{ module }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.application.pluginManagementEnabled"
|
||||
@change="togglePlugin(item.application.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
插件化管理
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<form class="form-grid" @submit.prevent="upload(item.application.id)">
|
||||
<label>
|
||||
包类型
|
||||
<select v-model="uploadForms[item.application.id].packageType">
|
||||
<option value="APP">App</option>
|
||||
<option value="PLUGIN">插件</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
版本号
|
||||
<input v-model="uploadForms[item.application.id].versionName" placeholder="0.3.0" required />
|
||||
</label>
|
||||
<label>
|
||||
版本码
|
||||
<input v-model.number="uploadForms[item.application.id].versionCode" type="number" min="1" required />
|
||||
</label>
|
||||
<label>
|
||||
包文件名
|
||||
<input v-model="uploadForms[item.application.id].uploadedFileName" placeholder="app-release.apk" required />
|
||||
</label>
|
||||
<label class="full">
|
||||
下载地址
|
||||
<input v-model="uploadForms[item.application.id].downloadUrl" placeholder="https://example.com/app.apk" required />
|
||||
</label>
|
||||
<label class="full">
|
||||
更新标题
|
||||
<input v-model="uploadForms[item.application.id].title" placeholder="发现新版本" required />
|
||||
</label>
|
||||
<label class="full">
|
||||
更新说明
|
||||
<textarea v-model="uploadForms[item.application.id].changelog" rows="3"></textarea>
|
||||
</label>
|
||||
<button class="primary">上传版本包</button>
|
||||
</form>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>版本</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>策略</th>
|
||||
<th>灰度目标</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="release in item.releases" :key="release.id">
|
||||
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
||||
<td>{{ release.packageType }}</td>
|
||||
<td>{{ release.status }}</td>
|
||||
<td>{{ release.publishStrategy }}</td>
|
||||
<td>{{ graySummary(release) }}</td>
|
||||
<td class="actions">
|
||||
<button class="ghost" @click="publishFull(item.application.id, release.id)">全量发布</button>
|
||||
<button class="ghost" @click="selectGrayRelease(item.application.id, release.id)">灰度发布</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-tag">灰度发布</p>
|
||||
<h2>用户平台 Hook 选人</h2>
|
||||
</div>
|
||||
<p class="muted">用户数据已脱敏,可按分组、快速选择或单个用户圈定。</p>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<label>
|
||||
分组
|
||||
<select v-model="filters.groupCode" @change="loadUsers">
|
||||
<option value="">全部</option>
|
||||
<option v-for="group in groups" :key="group.code" :value="group.code">
|
||||
{{ group.name }} ({{ group.userCount }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
快速选择
|
||||
<select v-model="filters.quickSelectionCode" @change="loadUsers">
|
||||
<option value="">全部</option>
|
||||
<option v-for="item in quickSelections" :key="item.code" :value="item.code">
|
||||
{{ item.name }} ({{ item.userCount }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grow">
|
||||
搜索
|
||||
<input v-model="filters.keyword" placeholder="用户 ID / 昵称 / 地区" @input="loadUsers" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="selected-bar">
|
||||
<span>当前灰度版本:{{ selectedReleaseId || '请先点一个版本的灰度发布' }}</span>
|
||||
<span>已选择用户:{{ selectedUsers.length }}</span>
|
||||
<button class="primary" :disabled="!selectedReleaseId" @click="publishGray">确认灰度发布</button>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="item in quickSelections"
|
||||
:key="item.code"
|
||||
class="chip-button"
|
||||
@click="applyQuickSelection(item.code)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>用户 ID</th>
|
||||
<th>昵称</th>
|
||||
<th>手机号</th>
|
||||
<th>邮箱</th>
|
||||
<th>地区</th>
|
||||
<th>分组</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>
|
||||
<input type="checkbox" :checked="selectedUsers.includes(user.id)" @change="toggleUser(user.id)" />
|
||||
</td>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.nickname }}</td>
|
||||
<td>{{ user.phone }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.region }}</td>
|
||||
<td>{{ user.groupName }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type QuickSelection, type ReleaseRecord } from '../api/client'
|
||||
|
||||
const applications = ref<ApplicationDetail[]>([])
|
||||
const groups = ref<AudienceGroup[]>([])
|
||||
const quickSelections = ref<QuickSelection[]>([])
|
||||
const users = ref<AudienceUser[]>([])
|
||||
const selectedUsers = ref<string[]>([])
|
||||
const selectedAppId = ref('')
|
||||
const selectedReleaseId = ref('')
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
groupCode: '',
|
||||
quickSelectionCode: '',
|
||||
})
|
||||
|
||||
const uploadForms = reactive<Record<string, {
|
||||
packageType: 'APP' | 'PLUGIN'
|
||||
versionCode: number
|
||||
versionName: string
|
||||
title: string
|
||||
changelog: string
|
||||
downloadUrl: string
|
||||
uploadedFileName: string
|
||||
entryActivity: string
|
||||
forceUpdate: boolean
|
||||
}>>({})
|
||||
|
||||
function ensureForm(appId: string) {
|
||||
if (!uploadForms[appId]) {
|
||||
uploadForms[appId] = {
|
||||
packageType: 'APP',
|
||||
versionCode: 1,
|
||||
versionName: '',
|
||||
title: '发现新版本',
|
||||
changelog: '',
|
||||
downloadUrl: '',
|
||||
uploadedFileName: '',
|
||||
entryActivity: 'com.xuqm.plugin.ui.PluginUiActivity',
|
||||
forceUpdate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function graySummary(release: ReleaseRecord) {
|
||||
if (!release.grayRule) return '全量'
|
||||
const rule = release.grayRule
|
||||
return `组 ${rule.groupCodes.length} / 快选 ${rule.quickSelectionCodes.length} / 用户 ${rule.userIds.length}`
|
||||
}
|
||||
|
||||
function selectGrayRelease(appId: string, releaseId: string) {
|
||||
selectedAppId.value = appId
|
||||
selectedReleaseId.value = releaseId
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [appList, groupList, quickList] = await Promise.all([
|
||||
api.listApplications(),
|
||||
api.listAudienceGroups(),
|
||||
api.listQuickSelections(),
|
||||
])
|
||||
applications.value = appList
|
||||
groups.value = groupList
|
||||
quickSelections.value = quickList
|
||||
appList.forEach(item => ensureForm(item.application.id))
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
users.value = await api.listAudienceUsers(filters)
|
||||
}
|
||||
|
||||
async function togglePlugin(appId: string, enabled: boolean) {
|
||||
await api.togglePluginManagement(appId, enabled)
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
async function upload(appId: string) {
|
||||
const form = uploadForms[appId]
|
||||
await api.uploadRelease(appId, form)
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
async function publishFull(appId: string, releaseId: string) {
|
||||
await api.publishRelease(appId, releaseId, { grayPublish: false })
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
function toggleUser(userId: string) {
|
||||
if (selectedUsers.value.includes(userId)) {
|
||||
selectedUsers.value = selectedUsers.value.filter(item => item !== userId)
|
||||
} else {
|
||||
selectedUsers.value = [...selectedUsers.value, userId]
|
||||
}
|
||||
}
|
||||
|
||||
function applyQuickSelection(code: string) {
|
||||
filters.quickSelectionCode = code
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
async function publishGray() {
|
||||
if (!selectedReleaseId.value || !selectedAppId.value) return
|
||||
await api.publishRelease(selectedAppId.value, selectedReleaseId.value, {
|
||||
grayPublish: true,
|
||||
hookName: 'user-platform-gray-hook',
|
||||
groupCodes: filters.groupCode ? [filters.groupCode] : [],
|
||||
quickSelectionCodes: filters.quickSelectionCode ? [filters.quickSelectionCode] : [],
|
||||
userIds: selectedUsers.value,
|
||||
})
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
</script>
|
||||
@ -48,6 +48,7 @@
|
||||
<target>${maven.compiler.target}</target>
|
||||
<release>${maven.compiler.release}</release>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
|
||||
@ -45,6 +45,11 @@
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.dongliu</groupId>
|
||||
<artifactId>apk-parser</artifactId>
|
||||
<version>2.6.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
@ -70,6 +75,7 @@
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<release>${java.version}</release>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@ -25,8 +25,8 @@ public class CompatibilityUpdateController {
|
||||
|
||||
@GetMapping("/api/v1/updates/app/latest")
|
||||
public ApiResponse<Map<String, Object>> latestApp(
|
||||
@RequestParam String packageName,
|
||||
@RequestParam(required = false) String userId
|
||||
@RequestParam("packageName") String packageName,
|
||||
@RequestParam(value = "userId", required = false) String userId
|
||||
) {
|
||||
PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId);
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
@ -42,9 +42,9 @@ public class CompatibilityUpdateController {
|
||||
|
||||
@GetMapping("/api/v1/updates/plugin/latest")
|
||||
public ApiResponse<Map<String, Object>> latestPlugin(
|
||||
@RequestParam String packageName,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) Integer hostVersionCode
|
||||
@RequestParam("packageName") String packageName,
|
||||
@RequestParam(value = "userId", required = false) String userId,
|
||||
@RequestParam(value = "hostVersionCode", required = false) Integer hostVersionCode
|
||||
) {
|
||||
PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode);
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
@ -60,9 +60,9 @@ public class CompatibilityUpdateController {
|
||||
|
||||
@GetMapping("/api/v1/updates/plugins/catalog")
|
||||
public ApiResponse<java.util.List<VersionManagementService.PluginCatalogItem>> pluginCatalog(
|
||||
@RequestParam String appPackageName,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam long hostVersionCode
|
||||
@RequestParam("appPackageName") String appPackageName,
|
||||
@RequestParam(value = "userId", required = false) String userId,
|
||||
@RequestParam("hostVersionCode") long hostVersionCode
|
||||
) {
|
||||
return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, hostVersionCode));
|
||||
}
|
||||
|
||||
@ -2,12 +2,21 @@ package com.xuqm.versionmanagement.controller;
|
||||
|
||||
import com.xuqm.versionmanagement.model.ApiResponse;
|
||||
import com.xuqm.versionmanagement.model.PlatformData;
|
||||
import com.xuqm.versionmanagement.service.ReleaseArtifactService;
|
||||
import com.xuqm.versionmanagement.service.UserHookService;
|
||||
import com.xuqm.versionmanagement.service.VersionManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@ -15,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@ -23,10 +33,16 @@ public class OpsVersionController {
|
||||
|
||||
private final VersionManagementService versionManagementService;
|
||||
private final UserHookService userHookService;
|
||||
private final ReleaseArtifactService releaseArtifactService;
|
||||
|
||||
public OpsVersionController(VersionManagementService versionManagementService, UserHookService userHookService) {
|
||||
public OpsVersionController(
|
||||
VersionManagementService versionManagementService,
|
||||
UserHookService userHookService,
|
||||
ReleaseArtifactService releaseArtifactService
|
||||
) {
|
||||
this.versionManagementService = versionManagementService;
|
||||
this.userHookService = userHookService;
|
||||
this.releaseArtifactService = releaseArtifactService;
|
||||
}
|
||||
|
||||
@GetMapping("/apps")
|
||||
@ -107,6 +123,18 @@ public class OpsVersionController {
|
||||
return ApiResponse.success(release, "版本包已上传");
|
||||
}
|
||||
|
||||
@PostMapping(value = "/apps/{appId}/packages/artifact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<ReleaseArtifactService.UploadedArtifact> uploadAppArtifact(
|
||||
@PathVariable String appId,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return ApiResponse.success(
|
||||
releaseArtifactService.uploadAppArtifact(appId, file, baseUrl(request)),
|
||||
"安装包上传成功"
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/apps/{appId}/plugins")
|
||||
public ApiResponse<List<PlatformData.PluginConfig>> listPlugins(@PathVariable String appId) {
|
||||
return ApiResponse.success(versionManagementService.listPlugins(appId));
|
||||
@ -174,6 +202,18 @@ public class OpsVersionController {
|
||||
return ApiResponse.success(release, "插件安装包已上传");
|
||||
}
|
||||
|
||||
@PostMapping(value = "/plugins/{pluginId}/packages/artifact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<ReleaseArtifactService.UploadedArtifact> uploadPluginArtifact(
|
||||
@PathVariable String pluginId,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return ApiResponse.success(
|
||||
releaseArtifactService.uploadPluginArtifact(pluginId, file, baseUrl(request)),
|
||||
"插件安装包上传成功"
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/packages/{releaseId}/publish")
|
||||
public ApiResponse<PlatformData.ReleaseRecord> publishRelease(
|
||||
@PathVariable String releaseId,
|
||||
@ -192,6 +232,36 @@ public class OpsVersionController {
|
||||
return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功");
|
||||
}
|
||||
|
||||
@PutMapping("/packages/{releaseId}")
|
||||
public ApiResponse<PlatformData.ReleaseRecord> updateRelease(
|
||||
@PathVariable String releaseId,
|
||||
@RequestBody @Validated UpdateReleaseRequest request
|
||||
) {
|
||||
PlatformData.ReleaseRecord release = versionManagementService.updateRelease(
|
||||
releaseId,
|
||||
new VersionManagementService.UpdateReleaseCommand(
|
||||
request.packageName(),
|
||||
request.versionCode(),
|
||||
request.versionName(),
|
||||
request.title(),
|
||||
request.changelog(),
|
||||
request.downloadUrl(),
|
||||
request.entryActivity(),
|
||||
request.minHostVersionCode(),
|
||||
request.minHostVersionName(),
|
||||
request.forceUpdate(),
|
||||
request.uploadedFileName()
|
||||
)
|
||||
);
|
||||
return ApiResponse.success(release, "安装包信息已更新");
|
||||
}
|
||||
|
||||
@DeleteMapping("/packages/{releaseId}")
|
||||
public ApiResponse<Void> deleteRelease(@PathVariable String releaseId) {
|
||||
versionManagementService.deleteRelease(releaseId);
|
||||
return ApiResponse.success(null, "安装包已删除");
|
||||
}
|
||||
|
||||
@GetMapping("/audiences/users")
|
||||
public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@ -211,6 +281,15 @@ public class OpsVersionController {
|
||||
return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections());
|
||||
}
|
||||
|
||||
@GetMapping("/assets/download/{storageKey}")
|
||||
public ResponseEntity<Resource> downloadArtifact(@PathVariable String storageKey) {
|
||||
ReleaseArtifactService.DownloadResource resource = releaseArtifactService.loadAsResource(storageKey);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.fileName() + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(resource.resource());
|
||||
}
|
||||
|
||||
public record AppRequest(
|
||||
@NotBlank(message = "不能为空") String name,
|
||||
@NotBlank(message = "不能为空") String packageName,
|
||||
@ -270,6 +349,21 @@ public class OpsVersionController {
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateReleaseRequest(
|
||||
String packageName,
|
||||
int versionCode,
|
||||
@NotBlank(message = "不能为空") String versionName,
|
||||
@NotBlank(message = "不能为空") String title,
|
||||
String changelog,
|
||||
@NotBlank(message = "不能为空") String downloadUrl,
|
||||
String entryActivity,
|
||||
Integer minHostVersionCode,
|
||||
String minHostVersionName,
|
||||
boolean forceUpdate,
|
||||
@NotBlank(message = "不能为空") String uploadedFileName
|
||||
) {
|
||||
}
|
||||
|
||||
public record PublishReleaseRequest(
|
||||
boolean grayPublish,
|
||||
String hookName,
|
||||
@ -289,4 +383,8 @@ public class OpsVersionController {
|
||||
return userIds == null ? List.of() : userIds;
|
||||
}
|
||||
}
|
||||
|
||||
private String baseUrl(HttpServletRequest request) {
|
||||
return request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
package com.xuqm.versionmanagement.service;
|
||||
|
||||
import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity;
|
||||
import com.xuqm.versionmanagement.persistence.entity.PluginEntity;
|
||||
import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository;
|
||||
import com.xuqm.versionmanagement.persistence.repository.PluginRepository;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import net.dongliu.apk.parser.ApkFile;
|
||||
import net.dongliu.apk.parser.bean.ApkMeta;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class ReleaseArtifactService {
|
||||
|
||||
private final ApplicationRepository applicationRepository;
|
||||
private final PluginRepository pluginRepository;
|
||||
private final Path releaseDir;
|
||||
|
||||
public ReleaseArtifactService(
|
||||
ApplicationRepository applicationRepository,
|
||||
PluginRepository pluginRepository,
|
||||
@Value("${app.storage.release-dir}") String releaseDir
|
||||
) {
|
||||
this.applicationRepository = applicationRepository;
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.releaseDir = Path.of(releaseDir).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public UploadedArtifact uploadAppArtifact(String appId, MultipartFile file, String baseUrl) {
|
||||
ApplicationEntity app = applicationRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("应用不存在"));
|
||||
ParsedApk parsedApk = parseAndStore(file);
|
||||
if (!app.getPackageName().equals(parsedApk.packageName())) {
|
||||
deleteQuietly(parsedApk.storageKey());
|
||||
throw new IllegalArgumentException("安装包包名与当前应用不一致,不允许上传");
|
||||
}
|
||||
return toUploadedArtifact(parsedApk, baseUrl);
|
||||
}
|
||||
|
||||
public UploadedArtifact uploadPluginArtifact(String pluginId, MultipartFile file, String baseUrl) {
|
||||
PluginEntity plugin = pluginRepository.findById(pluginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("插件不存在"));
|
||||
ParsedApk parsedApk = parseAndStore(file);
|
||||
if (!plugin.getPackageName().equals(parsedApk.packageName())) {
|
||||
deleteQuietly(parsedApk.storageKey());
|
||||
throw new IllegalArgumentException("安装包包名与当前插件不一致,不允许上传");
|
||||
}
|
||||
return toUploadedArtifact(parsedApk, baseUrl);
|
||||
}
|
||||
|
||||
public DownloadResource loadAsResource(String storageKey) {
|
||||
Path filePath = releaseDir.resolve(storageKey).normalize();
|
||||
if (!filePath.startsWith(releaseDir) || !Files.exists(filePath)) {
|
||||
throw new IllegalArgumentException("安装包文件不存在");
|
||||
}
|
||||
Resource resource = new FileSystemResource(filePath);
|
||||
return new DownloadResource(resource, filePath.getFileName().toString());
|
||||
}
|
||||
|
||||
private UploadedArtifact toUploadedArtifact(ParsedApk parsedApk, String baseUrl) {
|
||||
return new UploadedArtifact(
|
||||
parsedApk.packageName(),
|
||||
parsedApk.versionCode(),
|
||||
parsedApk.versionName(),
|
||||
parsedApk.originalFileName(),
|
||||
baseUrl + "/api/v1/ops/assets/download/" + parsedApk.storageKey()
|
||||
);
|
||||
}
|
||||
|
||||
private ParsedApk parseAndStore(MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("请选择安装包文件");
|
||||
}
|
||||
String originalFileName = file.getOriginalFilename() == null ? "release.apk" : Path.of(file.getOriginalFilename()).getFileName().toString();
|
||||
if (!originalFileName.toLowerCase(Locale.ROOT).endsWith(".apk")) {
|
||||
throw new IllegalArgumentException("仅支持上传 APK 文件");
|
||||
}
|
||||
|
||||
String storageKey = UUID.randomUUID() + "-" + originalFileName;
|
||||
Path target = releaseDir.resolve(storageKey);
|
||||
try {
|
||||
Files.createDirectories(releaseDir);
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
try (ApkFile apkFile = new ApkFile(target.toFile())) {
|
||||
ApkMeta meta = apkFile.getApkMeta();
|
||||
return new ParsedApk(
|
||||
meta.getPackageName(),
|
||||
meta.getVersionCode() == null ? 0 : meta.getVersionCode().intValue(),
|
||||
meta.getVersionName(),
|
||||
originalFileName,
|
||||
storageKey
|
||||
);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("安装包上传失败: " + exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
try {
|
||||
Files.deleteIfExists(target);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw new IllegalArgumentException("安装包解析失败,请确认文件为有效 APK");
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteByDownloadUrl(String downloadUrl) {
|
||||
if (downloadUrl == null || downloadUrl.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String path = URI.create(downloadUrl).getPath();
|
||||
String storageKey = path.substring(path.lastIndexOf('/') + 1);
|
||||
deleteQuietly(storageKey);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQuietly(String storageKey) {
|
||||
try {
|
||||
Files.deleteIfExists(releaseDir.resolve(storageKey));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public record UploadedArtifact(
|
||||
String packageName,
|
||||
int versionCode,
|
||||
String versionName,
|
||||
String uploadedFileName,
|
||||
String downloadUrl
|
||||
) {
|
||||
}
|
||||
|
||||
public record DownloadResource(Resource resource, String fileName) {
|
||||
}
|
||||
|
||||
private record ParsedApk(
|
||||
String packageName,
|
||||
int versionCode,
|
||||
String versionName,
|
||||
String originalFileName,
|
||||
String storageKey
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -22,19 +22,22 @@ public class VersionManagementService {
|
||||
private final ReleaseRepository releaseRepository;
|
||||
private final UserHookService userHookService;
|
||||
private final PlatformMapper platformMapper;
|
||||
private final ReleaseArtifactService releaseArtifactService;
|
||||
|
||||
public VersionManagementService(
|
||||
ApplicationRepository applicationRepository,
|
||||
PluginRepository pluginRepository,
|
||||
ReleaseRepository releaseRepository,
|
||||
UserHookService userHookService,
|
||||
PlatformMapper platformMapper
|
||||
PlatformMapper platformMapper,
|
||||
ReleaseArtifactService releaseArtifactService
|
||||
) {
|
||||
this.applicationRepository = applicationRepository;
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.releaseRepository = releaseRepository;
|
||||
this.userHookService = userHookService;
|
||||
this.platformMapper = platformMapper;
|
||||
this.releaseArtifactService = releaseArtifactService;
|
||||
}
|
||||
|
||||
public List<ApplicationDetail> listApplications() {
|
||||
@ -102,6 +105,35 @@ public class VersionManagementService {
|
||||
return platformMapper.toRelease(releaseRepository.save(entity));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlatformData.ReleaseRecord updateRelease(String releaseId, UpdateReleaseCommand command) {
|
||||
ReleaseEntity entity = findReleaseEntity(releaseId);
|
||||
if (command.packageName() != null && !command.packageName().isBlank()) {
|
||||
entity.setPackageName(command.packageName());
|
||||
}
|
||||
entity.setVersionCode(command.versionCode());
|
||||
entity.setVersionName(command.versionName());
|
||||
entity.setTitle(command.title());
|
||||
entity.setChangelog(command.changelog());
|
||||
entity.setDownloadUrl(command.downloadUrl());
|
||||
entity.setEntryActivity(command.entryActivity());
|
||||
entity.setMinHostVersionCode(command.minHostVersionCode());
|
||||
entity.setMinHostVersionName(command.minHostVersionName());
|
||||
entity.setForceUpdate(command.forceUpdate());
|
||||
entity.setUploadedFileName(command.uploadedFileName());
|
||||
return platformMapper.toRelease(releaseRepository.save(entity));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteRelease(String releaseId) {
|
||||
ReleaseEntity entity = findReleaseEntity(releaseId);
|
||||
if (entity.getStatus() != PlatformData.ReleaseStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("仅草稿状态安装包允许删除");
|
||||
}
|
||||
releaseArtifactService.deleteByDownloadUrl(entity.getDownloadUrl());
|
||||
releaseRepository.delete(entity);
|
||||
}
|
||||
|
||||
public List<PlatformData.PluginConfig> listPlugins(String appId) {
|
||||
findApplicationEntity(appId);
|
||||
return pluginRepository.findByAppIdOrderByCreatedAtAsc(appId).stream()
|
||||
@ -367,6 +399,21 @@ public class VersionManagementService {
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateReleaseCommand(
|
||||
String packageName,
|
||||
int versionCode,
|
||||
String versionName,
|
||||
String title,
|
||||
String changelog,
|
||||
String downloadUrl,
|
||||
String entryActivity,
|
||||
Integer minHostVersionCode,
|
||||
String minHostVersionName,
|
||||
boolean forceUpdate,
|
||||
String uploadedFileName
|
||||
) {
|
||||
}
|
||||
|
||||
public record PublishReleaseCommand(
|
||||
boolean grayPublish,
|
||||
String hookName,
|
||||
|
||||
@ -4,6 +4,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: version-management-service
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 200MB
|
||||
max-request-size: 200MB
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:mysql://39.107.53.187:3306/android-libs?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8}
|
||||
username: ${DB_USERNAME:android-libs}
|
||||
@ -46,3 +50,7 @@ spring:
|
||||
logging:
|
||||
level:
|
||||
org.hibernate.SQL: info
|
||||
|
||||
app:
|
||||
storage:
|
||||
release-dir: ${APP_RELEASE_DIR:${user.dir}/uploads/releases}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户