feat: refine ops package workflow and upload handling
这个提交包含在:
父节点
0314acc18e
当前提交
78e362eb22
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
*.tsbuildinfo
|
||||||
*.iml
|
*.iml
|
||||||
*.log
|
*.log
|
||||||
AndroidLibs/.gradle-home/
|
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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@ -24,7 +26,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.xuqm.sdk.CoreSDK
|
import com.xuqm.sdk.CoreSDK
|
||||||
import com.xuqm.sdk.compose.components.FeatureCard
|
|
||||||
import com.xuqm.szyx.SzyxSDK
|
import com.xuqm.szyx.SzyxSDK
|
||||||
import com.xuqm.szyx.auth.LoginSession
|
import com.xuqm.szyx.auth.LoginSession
|
||||||
import com.xuqm.szyx.auth.UserSessionManager
|
import com.xuqm.szyx.auth.UserSessionManager
|
||||||
@ -97,14 +98,14 @@ private fun PluginUiScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FeatureCard(
|
PluginInfoCard(
|
||||||
title = "插件独立运行",
|
title = "插件独立运行",
|
||||||
description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。",
|
description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FeatureCard(
|
PluginInfoCard(
|
||||||
title = "共享缓存",
|
title = "共享缓存",
|
||||||
description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。",
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -40,8 +41,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.xuqm.sdk.CoreSDK
|
import com.xuqm.sdk.CoreSDK
|
||||||
import com.xuqm.sdk.compose.components.AccordionGroup
|
import com.xuqm.sdk.compose.components.accordion.AccordionPanel
|
||||||
import com.xuqm.sdk.compose.components.FeatureCard
|
|
||||||
import com.xuqm.sdk.plugin.PluginPackageManager
|
import com.xuqm.sdk.plugin.PluginPackageManager
|
||||||
import com.xuqm.sdk.ui.ToastCenter
|
import com.xuqm.sdk.ui.ToastCenter
|
||||||
import com.xuqm.sdk.update.DownloadState
|
import com.xuqm.sdk.update.DownloadState
|
||||||
@ -485,7 +485,7 @@ private fun SampleHome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
AccordionGroup(title = "当前方案", initiallyExpanded = true) {
|
AccordionPanel(title = "当前方案", initiallyExpanded = true) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("1. 未登录启动时直接进入 lib-szyx 登录页")
|
Text("1. 未登录启动时直接进入 lib-szyx 登录页")
|
||||||
Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度")
|
Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度")
|
||||||
@ -495,7 +495,7 @@ private fun SampleHome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
FeatureCard(
|
InfoCard(
|
||||||
title = "插件结构",
|
title = "插件结构",
|
||||||
description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。",
|
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 {
|
private fun DownloadState.toDisplayText(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
DownloadState.Idle -> "未开始"
|
DownloadState.Idle -> "未开始"
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export interface ReleaseRecord {
|
|||||||
entryActivity?: string | null
|
entryActivity?: string | null
|
||||||
minHostVersionCode?: number | null
|
minHostVersionCode?: number | null
|
||||||
minHostVersionName?: string | null
|
minHostVersionName?: string | null
|
||||||
|
forceUpdate?: boolean
|
||||||
status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE'
|
status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE'
|
||||||
publishStrategy: string
|
publishStrategy: string
|
||||||
grayRule?: {
|
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'
|
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> {
|
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}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
...init,
|
...init,
|
||||||
@ -163,6 +165,20 @@ export const api = {
|
|||||||
body: JSON.stringify(payload),
|
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) {
|
listPlugins(appId: string) {
|
||||||
return request<PluginConfig[]>(`/api/v1/ops/apps/${appId}/plugins`)
|
return request<PluginConfig[]>(`/api/v1/ops/apps/${appId}/plugins`)
|
||||||
},
|
},
|
||||||
@ -187,12 +203,37 @@ export const api = {
|
|||||||
body: JSON.stringify(payload),
|
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>) {
|
publishPackage(releaseId: string, payload: Record<string, unknown>) {
|
||||||
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}/publish`, {
|
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}/publish`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
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() {
|
listAudienceGroups() {
|
||||||
return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups')
|
return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups')
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,10 +37,14 @@ select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@ -162,6 +166,11 @@ button {
|
|||||||
color: #163454;
|
color: #163454;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #fff1f1;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
.app-card {
|
.app-card {
|
||||||
border-top: 1px solid rgba(16, 35, 61, 0.08);
|
border-top: 1px solid rgba(16, 35, 61, 0.08);
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@ -200,6 +209,11 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wide-tabs button[disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.sub-panel {
|
.sub-panel {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
@ -248,6 +262,26 @@ button {
|
|||||||
margin-top: 12px;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.shell,
|
.shell,
|
||||||
.stack {
|
.stack {
|
||||||
@ -256,5 +290,7 @@ button {
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,147 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="page stack">
|
<section class="page">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="section-tag">App 管理</p>
|
<p class="section-tag">App 管理</p>
|
||||||
<h2>应用列表</h2>
|
<h2>{{ selectedApp ? selectedApp.application.name : '请选择应用' }}</h2>
|
||||||
|
<p class="muted">
|
||||||
|
{{ selectedApp ? selectedApp.application.packageName : '点击左侧 App 管理后,先从弹窗中选择一个应用。' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" @click="showCreateApp = !showCreateApp">创建 App</button>
|
<div class="actions">
|
||||||
</div>
|
<button class="secondary" @click="openSelector">切换应用</button>
|
||||||
|
<button v-if="selectedApp" class="primary" @click="openCreateModal">
|
||||||
<form v-if="showCreateApp" class="form-grid" @submit.prevent="createApp">
|
{{ tab === 'packages' ? '添加 APK' : '添加插件' }}
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" v-if="selectedApp">
|
<template v-if="selectedApp">
|
||||||
<div class="section-head">
|
<div class="selected-bar">
|
||||||
<div>
|
<div class="chips">
|
||||||
<p class="section-tag">当前应用</p>
|
<span>{{ selectedApp.application.pluginManagementEnabled ? '支持插件化' : '仅宿主 APK' }}</span>
|
||||||
<h2>{{ selectedApp.application.name }}</h2>
|
<span>{{ selectedApp.application.businessModules.join(' / ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary" @click="loadAll">刷新</button>
|
<button class="secondary" @click="loadSelectedApp">刷新当前应用</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="form-grid" @submit.prevent="saveApp">
|
<div class="tab-row wide-tabs">
|
||||||
<label><span>名称</span><input v-model="appEdit.name" required /></label>
|
<button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">APK 列表</button>
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
v-if="selectedApp.application.pluginManagementEnabled"
|
|
||||||
:class="tab === 'plugins' ? 'primary' : 'secondary'"
|
:class="tab === 'plugins' ? 'primary' : 'secondary'"
|
||||||
|
:disabled="!selectedApp.application.pluginManagementEnabled"
|
||||||
@click="tab = 'plugins'"
|
@click="tab = 'plugins'"
|
||||||
>
|
>
|
||||||
插件列表
|
插件列表
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="tab === 'packages'">
|
<div class="sub-panel">
|
||||||
<form class="form-grid" @submit.prevent="uploadAppPackage">
|
<div class="section-head">
|
||||||
<label><span>包名</span><input v-model="appPackageForm.packageName" required /></label>
|
<div>
|
||||||
<label><span>版本名</span><input v-model="appPackageForm.versionName" required /></label>
|
<p class="section-tag">{{ tab === 'packages' ? 'APK 管理' : '插件管理' }}</p>
|
||||||
<label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" required /></label>
|
<h3>{{ tab === 'packages' ? 'APK 安装包列表' : '插件安装包列表' }}</h3>
|
||||||
<label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" required /></label>
|
</div>
|
||||||
<label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" required /></label>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>版本</th><th>状态</th><th>操作</th></tr></thead>
|
<thead v-if="tab === 'packages'">
|
||||||
<tbody>
|
<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">
|
<tr v-for="release in appPackages" :key="release.id">
|
||||||
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
||||||
|
<td>{{ release.uploadedFileName }}</td>
|
||||||
<td>{{ release.status }}</td>
|
<td>{{ release.status }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="ghost" @click="publishFull(release.id)">发布当前安装包</button>
|
<button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
|
||||||
<button class="ghost" @click="prepareGray(release.id)">配置灰度</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
<tbody v-else>
|
||||||
</template>
|
<tr v-for="release in pluginReleaseRows" :key="release.id">
|
||||||
|
<td>{{ release.pluginName }}</td>
|
||||||
<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">
|
|
||||||
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
|
||||||
<td>{{ release.minHostVersionName || '-' }}</td>
|
<td>{{ release.minHostVersionName || '-' }}</td>
|
||||||
<td>{{ release.status }}</td>
|
<td>{{ release.status }}</td>
|
||||||
<td class="actions">
|
<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="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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="grayReleaseId" class="sub-panel">
|
<div v-if="grayReleaseId" class="sub-panel">
|
||||||
<h3>灰度信息配置</h3>
|
<h3>灰度信息配置</h3>
|
||||||
@ -176,94 +117,484 @@
|
|||||||
<button class="primary" @click="publishGray">确认灰度发布</button>
|
<button class="primary" @click="publishGray">确认灰度发布</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
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 applications = ref<ApplicationDetail[]>([])
|
||||||
const selectedApp = ref<ApplicationDetail | null>(null)
|
const selectedApp = ref<ApplicationDetail | null>(null)
|
||||||
const selectedPlugin = ref<PluginConfig | null>(null)
|
|
||||||
const appPackages = ref<ReleaseRecord[]>([])
|
const appPackages = ref<ReleaseRecord[]>([])
|
||||||
const pluginPackages = ref<ReleaseRecord[]>([])
|
|
||||||
const plugins = ref<PluginConfig[]>([])
|
const plugins = ref<PluginConfig[]>([])
|
||||||
|
const pluginReleaseRows = ref<PluginReleaseRow[]>([])
|
||||||
const users = ref<AudienceUser[]>([])
|
const users = ref<AudienceUser[]>([])
|
||||||
const groups = ref<AudienceGroup[]>([])
|
const groups = ref<AudienceGroup[]>([])
|
||||||
const quickSelections = ref<QuickSelection[]>([])
|
const quickSelections = ref<QuickSelection[]>([])
|
||||||
const selectedUsers = ref<string[]>([])
|
const selectedUsers = ref<string[]>([])
|
||||||
const grayReleaseId = ref('')
|
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 tab = ref<'packages' | 'plugins'>('packages')
|
||||||
|
|
||||||
const filters = reactive({ keyword: '', groupCode: '', quickSelectionCode: '' })
|
const filters = reactive({ keyword: '', groupCode: '', quickSelectionCode: '' })
|
||||||
const appForm = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] })
|
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 pluginForm = reactive({ name: '', packageName: '', entryActivity: '', description: '' })
|
||||||
const appPackageForm = reactive({ packageName: '', versionCode: 1, versionName: '', title: '发现新版本', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', forceUpdate: false })
|
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() {
|
async function loadAll() {
|
||||||
applications.value = await api.listApplications()
|
applications.value = await api.listApplications()
|
||||||
const [groupList, quickList] = await Promise.all([api.listAudienceGroups(), api.listQuickSelections()])
|
const [groupList, quickList] = await Promise.all([api.listAudienceGroups(), api.listQuickSelections()])
|
||||||
groups.value = groupList
|
groups.value = groupList
|
||||||
quickSelections.value = quickList
|
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
|
selectedApp.value = item
|
||||||
selectedPlugin.value = null
|
selectedUsers.value = []
|
||||||
|
grayReleaseId.value = ''
|
||||||
tab.value = 'packages'
|
tab.value = 'packages'
|
||||||
Object.assign(appEdit, item.application)
|
persistSelectedApp(item.application.id)
|
||||||
appPackageForm.packageName = item.application.packageName
|
|
||||||
appPackages.value = await api.listAppPackages(item.application.id)
|
appPackages.value = await api.listAppPackages(item.application.id)
|
||||||
plugins.value = await api.listPlugins(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)
|
await api.createApplication(appForm)
|
||||||
showCreateApp.value = false
|
} else {
|
||||||
|
await api.updateApplication(editingAppId.value, appForm)
|
||||||
|
}
|
||||||
|
closeAppModal()
|
||||||
await loadAll()
|
await loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveApp() {
|
async function submitAppRelease() {
|
||||||
if (!selectedApp.value) return
|
if (!selectedApp.value) return
|
||||||
await api.updateApplication(selectedApp.value.application.id, appEdit)
|
if (!appPackageForm.downloadUrl) {
|
||||||
await loadAll()
|
window.alert('请先选择并上传 APK 文件')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (releaseModalMode.value === 'create') {
|
||||||
async function uploadAppPackage() {
|
|
||||||
if (!selectedApp.value) return
|
|
||||||
await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm)
|
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)
|
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
|
if (!selectedApp.value) return
|
||||||
await api.createPlugin(selectedApp.value.application.id, pluginForm)
|
await api.createPlugin(selectedApp.value.application.id, pluginForm)
|
||||||
plugins.value = await api.listPlugins(selectedApp.value.application.id)
|
plugins.value = await api.listPlugins(selectedApp.value.application.id)
|
||||||
|
pluginCreateMode.value = 'package'
|
||||||
|
resetPluginForm()
|
||||||
|
resetPluginReleaseForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectPlugin(plugin: PluginConfig) {
|
async function onAppFileSelected(event: Event) {
|
||||||
selectedPlugin.value = plugin
|
if (!selectedApp.value) return
|
||||||
pluginPackages.value = await api.listPluginPackages(plugin.id)
|
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() {
|
async function onPluginFileSelected(event: Event) {
|
||||||
if (!selectedPlugin.value) return
|
const input = event.target as HTMLInputElement
|
||||||
await api.uploadPluginPackage(selectedPlugin.value.id, pluginPackageForm)
|
const file = input.files?.[0]
|
||||||
pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
|
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) {
|
async function publishFull(releaseId: string) {
|
||||||
await api.publishPackage(releaseId, { grayPublish: false })
|
await api.publishPackage(releaseId, { grayPublish: false })
|
||||||
if (selectedApp.value) appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
if (!selectedApp.value) return
|
||||||
if (selectedPlugin.value) pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
|
if (tab.value === 'packages') {
|
||||||
|
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
|
||||||
|
} else {
|
||||||
|
await refreshPluginReleaseRows()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareGray(releaseId: string) {
|
function prepareGray(releaseId: string) {
|
||||||
@ -291,6 +622,22 @@ async function publishGray() {
|
|||||||
userIds: selectedUsers.value,
|
userIds: selectedUsers.value,
|
||||||
})
|
})
|
||||||
grayReleaseId.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() })
|
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>
|
<target>${maven.compiler.target}</target>
|
||||||
<release>${maven.compiler.release}</release>
|
<release>${maven.compiler.release}</release>
|
||||||
<encoding>${project.build.sourceEncoding}</encoding>
|
<encoding>${project.build.sourceEncoding}</encoding>
|
||||||
|
<parameters>true</parameters>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@ -45,6 +45,11 @@
|
|||||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.dongliu</groupId>
|
||||||
|
<artifactId>apk-parser</artifactId>
|
||||||
|
<version>2.6.10</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.mysql</groupId>
|
<groupId>com.mysql</groupId>
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
@ -70,6 +75,7 @@
|
|||||||
<source>${maven.compiler.source}</source>
|
<source>${maven.compiler.source}</source>
|
||||||
<target>${maven.compiler.target}</target>
|
<target>${maven.compiler.target}</target>
|
||||||
<release>${java.version}</release>
|
<release>${java.version}</release>
|
||||||
|
<parameters>true</parameters>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@ -25,8 +25,8 @@ public class CompatibilityUpdateController {
|
|||||||
|
|
||||||
@GetMapping("/api/v1/updates/app/latest")
|
@GetMapping("/api/v1/updates/app/latest")
|
||||||
public ApiResponse<Map<String, Object>> latestApp(
|
public ApiResponse<Map<String, Object>> latestApp(
|
||||||
@RequestParam String packageName,
|
@RequestParam("packageName") String packageName,
|
||||||
@RequestParam(required = false) String userId
|
@RequestParam(value = "userId", required = false) String userId
|
||||||
) {
|
) {
|
||||||
PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId);
|
PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId);
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
@ -42,9 +42,9 @@ public class CompatibilityUpdateController {
|
|||||||
|
|
||||||
@GetMapping("/api/v1/updates/plugin/latest")
|
@GetMapping("/api/v1/updates/plugin/latest")
|
||||||
public ApiResponse<Map<String, Object>> latestPlugin(
|
public ApiResponse<Map<String, Object>> latestPlugin(
|
||||||
@RequestParam String packageName,
|
@RequestParam("packageName") String packageName,
|
||||||
@RequestParam(required = false) String userId,
|
@RequestParam(value = "userId", required = false) String userId,
|
||||||
@RequestParam(required = false) Integer hostVersionCode
|
@RequestParam(value = "hostVersionCode", required = false) Integer hostVersionCode
|
||||||
) {
|
) {
|
||||||
PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode);
|
PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode);
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
@ -60,9 +60,9 @@ public class CompatibilityUpdateController {
|
|||||||
|
|
||||||
@GetMapping("/api/v1/updates/plugins/catalog")
|
@GetMapping("/api/v1/updates/plugins/catalog")
|
||||||
public ApiResponse<java.util.List<VersionManagementService.PluginCatalogItem>> pluginCatalog(
|
public ApiResponse<java.util.List<VersionManagementService.PluginCatalogItem>> pluginCatalog(
|
||||||
@RequestParam String appPackageName,
|
@RequestParam("appPackageName") String appPackageName,
|
||||||
@RequestParam(required = false) String userId,
|
@RequestParam(value = "userId", required = false) String userId,
|
||||||
@RequestParam long hostVersionCode
|
@RequestParam("hostVersionCode") long hostVersionCode
|
||||||
) {
|
) {
|
||||||
return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, 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.ApiResponse;
|
||||||
import com.xuqm.versionmanagement.model.PlatformData;
|
import com.xuqm.versionmanagement.model.PlatformData;
|
||||||
|
import com.xuqm.versionmanagement.service.ReleaseArtifactService;
|
||||||
import com.xuqm.versionmanagement.service.UserHookService;
|
import com.xuqm.versionmanagement.service.UserHookService;
|
||||||
import com.xuqm.versionmanagement.service.VersionManagementService;
|
import com.xuqm.versionmanagement.service.VersionManagementService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.validation.annotation.Validated;
|
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.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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Validated
|
@Validated
|
||||||
@RestController
|
@RestController
|
||||||
@ -23,10 +33,16 @@ public class OpsVersionController {
|
|||||||
|
|
||||||
private final VersionManagementService versionManagementService;
|
private final VersionManagementService versionManagementService;
|
||||||
private final UserHookService userHookService;
|
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.versionManagementService = versionManagementService;
|
||||||
this.userHookService = userHookService;
|
this.userHookService = userHookService;
|
||||||
|
this.releaseArtifactService = releaseArtifactService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/apps")
|
@GetMapping("/apps")
|
||||||
@ -107,6 +123,18 @@ public class OpsVersionController {
|
|||||||
return ApiResponse.success(release, "版本包已上传");
|
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")
|
@GetMapping("/apps/{appId}/plugins")
|
||||||
public ApiResponse<List<PlatformData.PluginConfig>> listPlugins(@PathVariable String appId) {
|
public ApiResponse<List<PlatformData.PluginConfig>> listPlugins(@PathVariable String appId) {
|
||||||
return ApiResponse.success(versionManagementService.listPlugins(appId));
|
return ApiResponse.success(versionManagementService.listPlugins(appId));
|
||||||
@ -174,6 +202,18 @@ public class OpsVersionController {
|
|||||||
return ApiResponse.success(release, "插件安装包已上传");
|
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")
|
@PostMapping("/packages/{releaseId}/publish")
|
||||||
public ApiResponse<PlatformData.ReleaseRecord> publishRelease(
|
public ApiResponse<PlatformData.ReleaseRecord> publishRelease(
|
||||||
@PathVariable String releaseId,
|
@PathVariable String releaseId,
|
||||||
@ -192,6 +232,36 @@ public class OpsVersionController {
|
|||||||
return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功");
|
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")
|
@GetMapping("/audiences/users")
|
||||||
public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers(
|
public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers(
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@ -211,6 +281,15 @@ public class OpsVersionController {
|
|||||||
return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections());
|
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(
|
public record AppRequest(
|
||||||
@NotBlank(message = "不能为空") String name,
|
@NotBlank(message = "不能为空") String name,
|
||||||
@NotBlank(message = "不能为空") String packageName,
|
@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(
|
public record PublishReleaseRequest(
|
||||||
boolean grayPublish,
|
boolean grayPublish,
|
||||||
String hookName,
|
String hookName,
|
||||||
@ -289,4 +383,8 @@ public class OpsVersionController {
|
|||||||
return userIds == null ? List.of() : userIds;
|
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 ReleaseRepository releaseRepository;
|
||||||
private final UserHookService userHookService;
|
private final UserHookService userHookService;
|
||||||
private final PlatformMapper platformMapper;
|
private final PlatformMapper platformMapper;
|
||||||
|
private final ReleaseArtifactService releaseArtifactService;
|
||||||
|
|
||||||
public VersionManagementService(
|
public VersionManagementService(
|
||||||
ApplicationRepository applicationRepository,
|
ApplicationRepository applicationRepository,
|
||||||
PluginRepository pluginRepository,
|
PluginRepository pluginRepository,
|
||||||
ReleaseRepository releaseRepository,
|
ReleaseRepository releaseRepository,
|
||||||
UserHookService userHookService,
|
UserHookService userHookService,
|
||||||
PlatformMapper platformMapper
|
PlatformMapper platformMapper,
|
||||||
|
ReleaseArtifactService releaseArtifactService
|
||||||
) {
|
) {
|
||||||
this.applicationRepository = applicationRepository;
|
this.applicationRepository = applicationRepository;
|
||||||
this.pluginRepository = pluginRepository;
|
this.pluginRepository = pluginRepository;
|
||||||
this.releaseRepository = releaseRepository;
|
this.releaseRepository = releaseRepository;
|
||||||
this.userHookService = userHookService;
|
this.userHookService = userHookService;
|
||||||
this.platformMapper = platformMapper;
|
this.platformMapper = platformMapper;
|
||||||
|
this.releaseArtifactService = releaseArtifactService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ApplicationDetail> listApplications() {
|
public List<ApplicationDetail> listApplications() {
|
||||||
@ -102,6 +105,35 @@ public class VersionManagementService {
|
|||||||
return platformMapper.toRelease(releaseRepository.save(entity));
|
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) {
|
public List<PlatformData.PluginConfig> listPlugins(String appId) {
|
||||||
findApplicationEntity(appId);
|
findApplicationEntity(appId);
|
||||||
return pluginRepository.findByAppIdOrderByCreatedAtAsc(appId).stream()
|
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(
|
public record PublishReleaseCommand(
|
||||||
boolean grayPublish,
|
boolean grayPublish,
|
||||||
String hookName,
|
String hookName,
|
||||||
|
|||||||
@ -4,6 +4,10 @@ server:
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: version-management-service
|
name: version-management-service
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 200MB
|
||||||
|
max-request-size: 200MB
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:mysql://39.107.53.187:3306/android-libs?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8}
|
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}
|
username: ${DB_USERNAME:android-libs}
|
||||||
@ -46,3 +50,7 @@ spring:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
org.hibernate.SQL: info
|
org.hibernate.SQL: info
|
||||||
|
|
||||||
|
app:
|
||||||
|
storage:
|
||||||
|
release-dir: ${APP_RELEASE_DIR:${user.dir}/uploads/releases}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户