Sfoglia il codice sorgente

feat: refine ops package workflow and upload handling

徐勤民 6 ore fa
parent
commit
78e362eb22

+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@ node_modules/
 dist/
 dist/
 coverage/
 coverage/
 .pnp.*
 .pnp.*
+*.tsbuildinfo
 *.iml
 *.iml
 *.log
 *.log
 AndroidLibs/.gradle-home/
 AndroidLibs/.gradle-home/

+ 0 - 58
AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/AccordionGroup.kt

@@ -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()
-                }
-            }
-        }
-    }
-}

+ 0 - 29
AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/composesdk/components/FeatureCard.kt

@@ -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)
-        }
-    }
-}

+ 24 - 3
AndroidLibs/plugins/plugin-ui/src/main/java/com/xuqm/plugin/ui/PluginUiActivity.kt

@@ -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 - 4
AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt

@@ -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.FeatureCard
+import com.xuqm.sdk.compose.components.accordion.AccordionPanel
 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 -> "未开始"

+ 42 - 1
frontend/ops-platform/src/api/client.ts

@@ -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')
   },
   },

+ 36 - 0
frontend/ops-platform/src/styles.css

@@ -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;
   }
   }
 }
 }

+ 524 - 177
frontend/ops-platform/src/views/AppManagementView.vue

@@ -1,269 +1,600 @@
 <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>
-
-      <form v-if="showCreateApp" class="form-grid" @submit.prevent="createApp">
-        <label><span>名称</span><input v-model="appForm.name" required /></label>
-        <label><span>包名</span><input v-model="appForm.packageName" required /></label>
-        <label><span>插件包名前缀</span><input v-model="appForm.pluginPackageName" /></label>
-        <label class="full"><span>说明</span><input v-model="appForm.description" /></label>
-        <label class="toggle"><input type="checkbox" v-model="appForm.pluginManagementEnabled" />支持插件化</label>
-        <button class="primary">保存 App</button>
-      </form>
-
-      <div class="list-grid">
-        <button
-          v-for="item in applications"
-          :key="item.application.id"
-          class="list-card"
-          :data-active="selectedApp?.application.id === item.application.id"
-          @click="selectApp(item)"
-        >
-          <strong>{{ item.application.name }}</strong>
-          <span>{{ item.application.packageName }}</span>
-          <span>{{ item.application.pluginManagementEnabled ? '支持插件化' : '仅宿主' }}</span>
-        </button>
-      </div>
-    </div>
-
-    <div class="panel" v-if="selectedApp">
-      <div class="section-head">
-        <div>
-          <p class="section-tag">当前应用</p>
-          <h2>{{ selectedApp.application.name }}</h2>
+        <div class="actions">
+          <button class="secondary" @click="openSelector">切换应用</button>
+          <button v-if="selectedApp" class="primary" @click="openCreateModal">
+            {{ tab === 'packages' ? '添加 APK' : '添加插件' }}
+          </button>
         </div>
         </div>
-        <button class="secondary" @click="loadAll">刷新</button>
-      </div>
-
-      <form class="form-grid" @submit.prevent="saveApp">
-        <label><span>名称</span><input v-model="appEdit.name" required /></label>
-        <label><span>包名</span><input v-model="appEdit.packageName" required /></label>
-        <label><span>插件包名前缀</span><input v-model="appEdit.pluginPackageName" /></label>
-        <label class="full"><span>说明</span><input v-model="appEdit.description" /></label>
-        <label class="toggle"><input type="checkbox" v-model="appEdit.pluginManagementEnabled" />支持插件化</label>
-        <button class="primary">修改 App 信息</button>
-      </form>
-
-      <div class="tab-row">
-        <button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">安装包</button>
-        <button
-          v-if="selectedApp.application.pluginManagementEnabled"
-          :class="tab === 'plugins' ? 'primary' : 'secondary'"
-          @click="tab = 'plugins'"
-        >
-          插件列表
-        </button>
       </div>
       </div>
 
 
-      <template v-if="tab === 'packages'">
-        <form class="form-grid" @submit.prevent="uploadAppPackage">
-          <label><span>包名</span><input v-model="appPackageForm.packageName" required /></label>
-          <label><span>版本名</span><input v-model="appPackageForm.versionName" required /></label>
-          <label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" required /></label>
-          <label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" required /></label>
-          <label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" required /></label>
-          <label class="full"><span>标题</span><input v-model="appPackageForm.title" required /></label>
-          <label class="full"><span>更新说明</span><textarea v-model="appPackageForm.changelog" rows="3" /></label>
-          <button class="primary">上传安装包</button>
-        </form>
-
-        <table class="table">
-          <thead><tr><th>版本</th><th>状态</th><th>操作</th></tr></thead>
-          <tbody>
-            <tr v-for="release in appPackages" :key="release.id">
-              <td>{{ release.versionName }} ({{ release.versionCode }})</td>
-              <td>{{ release.status }}</td>
-              <td class="actions">
-                <button class="ghost" @click="publishFull(release.id)">发布当前安装包</button>
-                <button class="ghost" @click="prepareGray(release.id)">配置灰度</button>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </template>
-
-      <template v-else>
-        <form class="form-grid" @submit.prevent="createPlugin">
-          <label><span>插件名</span><input v-model="pluginForm.name" required /></label>
-          <label><span>插件包名</span><input v-model="pluginForm.packageName" required /></label>
-          <label class="full"><span>入口 Activity</span><input v-model="pluginForm.entryActivity" /></label>
-          <label class="full"><span>说明</span><input v-model="pluginForm.description" /></label>
-          <button class="primary">新建插件</button>
-        </form>
+      <template v-if="selectedApp">
+        <div class="selected-bar">
+          <div class="chips">
+            <span>{{ selectedApp.application.pluginManagementEnabled ? '支持插件化' : '仅宿主 APK' }}</span>
+            <span>{{ selectedApp.application.businessModules.join(' / ') }}</span>
+          </div>
+          <button class="secondary" @click="loadSelectedApp">刷新当前应用</button>
+        </div>
 
 
-        <div class="list-grid">
+        <div class="tab-row wide-tabs">
+          <button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">APK 列表</button>
           <button
           <button
-            v-for="plugin in plugins"
-            :key="plugin.id"
-            class="list-card"
-            :data-active="selectedPlugin?.id === plugin.id"
-            @click="selectPlugin(plugin)"
+            :class="tab === 'plugins' ? 'primary' : 'secondary'"
+            :disabled="!selectedApp.application.pluginManagementEnabled"
+            @click="tab = 'plugins'"
           >
           >
-            <strong>{{ plugin.name }}</strong>
-            <span>{{ plugin.packageName }}</span>
-            <span>{{ plugin.enabled ? '已启用' : '未启用' }}</span>
+            插件列表
           </button>
           </button>
         </div>
         </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>
+        <div class="sub-panel">
+          <div class="section-head">
+            <div>
+              <p class="section-tag">{{ tab === 'packages' ? 'APK 管理' : '插件管理' }}</p>
+              <h3>{{ tab === 'packages' ? 'APK 安装包列表' : '插件安装包列表' }}</h3>
+            </div>
+          </div>
 
 
           <table class="table">
           <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">
+            <thead v-if="tab === 'packages'">
+              <tr><th>版本</th><th>文件名</th><th>状态</th><th>操作</th></tr>
+            </thead>
+            <thead v-else>
+              <tr><th>插件</th><th>版本</th><th>宿主最低版本</th><th>状态</th><th>操作</th></tr>
+            </thead>
+            <tbody v-if="tab === 'packages'">
+              <tr v-for="release in appPackages" :key="release.id">
+                <td>{{ release.versionName }} ({{ release.versionCode }})</td>
+                <td>{{ release.uploadedFileName }}</td>
+                <td>{{ release.status }}</td>
+                <td class="actions">
+                  <button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
+                  <button class="ghost" @click="publishFull(release.id)">发布</button>
+                  <button class="ghost" @click="prepareGray(release.id)">灰度</button>
+                  <button class="ghost" @click="openEditReleaseModal(release, 'APP')">编辑</button>
+                  <button v-if="release.status === 'DRAFT'" class="ghost danger" @click="removeRelease(release.id)">删除</button>
+                </td>
+              </tr>
+            </tbody>
+            <tbody v-else>
+              <tr v-for="release in pluginReleaseRows" :key="release.id">
+                <td>{{ release.pluginName }}</td>
                 <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>
+
+        <div v-if="grayReleaseId" class="sub-panel">
+          <h3>灰度信息配置</h3>
+          <div class="filters">
+            <label><span>分组</span>
+              <select v-model="filters.groupCode" @change="loadUsers">
+                <option value="">全部</option>
+                <option v-for="group in groups" :key="group.code" :value="group.code">{{ group.name }}</option>
+              </select>
+            </label>
+            <label><span>快选</span>
+              <select v-model="filters.quickSelectionCode" @change="loadUsers">
+                <option value="">全部</option>
+                <option v-for="item in quickSelections" :key="item.code" :value="item.code">{{ item.name }}</option>
+              </select>
+            </label>
+            <label class="grow"><span>搜索</span><input v-model="filters.keyword" @input="loadUsers" /></label>
+          </div>
+          <table class="table">
+            <thead><tr><th></th><th>ID</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.region }}</td>
+                <td>{{ user.groupName }}</td>
+              </tr>
+            </tbody>
+          </table>
+          <div class="actions">
+            <button class="primary" @click="publishGray">确认灰度发布</button>
+          </div>
+        </div>
       </template>
       </template>
+    </div>
 
 
-      <div v-if="grayReleaseId" class="sub-panel">
-        <h3>灰度信息配置</h3>
-        <div class="filters">
-          <label><span>分组</span>
-            <select v-model="filters.groupCode" @change="loadUsers">
-              <option value="">全部</option>
-              <option v-for="group in groups" :key="group.code" :value="group.code">{{ group.name }}</option>
-            </select>
-          </label>
-          <label><span>快选</span>
-            <select v-model="filters.quickSelectionCode" @change="loadUsers">
-              <option value="">全部</option>
-              <option v-for="item in quickSelections" :key="item.code" :value="item.code">{{ item.name }}</option>
-            </select>
-          </label>
-          <label class="grow"><span>搜索</span><input v-model="filters.keyword" @input="loadUsers" /></label>
+    <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>
-        <table class="table">
-          <thead><tr><th></th><th>ID</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.region }}</td>
-              <td>{{ user.groupName }}</td>
-            </tr>
-          </tbody>
-        </table>
-        <div class="actions">
-          <button class="primary" @click="publishGray">确认灰度发布</button>
+
+        <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>
         </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>
     </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 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) {
+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)
-  appPackageForm.packageName = item.application.packageName
+  persistSelectedApp(item.application.id)
   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() {
-  await api.createApplication(appForm)
-  showCreateApp.value = false
+async function submitAppModal() {
+  if (appModalMode.value === 'create') {
+    await api.createApplication(appForm)
+  } 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)
-  await loadAll()
+  if (!appPackageForm.downloadUrl) {
+    window.alert('请先选择并上传 APK 文件')
+    return
+  }
+  if (releaseModalMode.value === 'create') {
+    await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm)
+  } else {
+    await api.updatePackage(editingReleaseId.value, {
+      versionCode: appPackageForm.versionCode,
+      versionName: appPackageForm.versionName,
+      title: appPackageForm.title,
+      changelog: appPackageForm.changelog,
+      downloadUrl: appPackageForm.downloadUrl,
+      uploadedFileName: appPackageForm.uploadedFileName,
+      entryActivity: appPackageForm.entryActivity,
+      forceUpdate: appPackageForm.forceUpdate,
+    })
+  }
+  closeReleaseModal()
+  appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
 }
 }
 
 
-async function uploadAppPackage() {
+async function submitPluginRelease() {
   if (!selectedApp.value) return
   if (!selectedApp.value) return
-  await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm)
-  appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
+  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 createPlugin() {
+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) {
-  selectedPlugin.value = plugin
-  pluginPackages.value = await api.listPluginPackages(plugin.id)
+async function onAppFileSelected(event: Event) {
+  if (!selectedApp.value) return
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+  try {
+    uploadingArtifact.value = true
+    const artifact = await api.uploadAppArtifact(selectedApp.value.application.id, file)
+    appPackageForm.packageName = artifact.packageName
+    appPackageForm.versionCode = artifact.versionCode
+    appPackageForm.versionName = artifact.versionName
+    appPackageForm.uploadedFileName = artifact.uploadedFileName
+    appPackageForm.downloadUrl = artifact.downloadUrl
+  } catch (error) {
+    window.alert(error instanceof Error ? error.message : 'APK 上传失败')
+    input.value = ''
+  } finally {
+    uploadingArtifact.value = false
+  }
 }
 }
 
 
-async function uploadPluginPackage() {
-  if (!selectedPlugin.value) return
-  await api.uploadPluginPackage(selectedPlugin.value.id, pluginPackageForm)
-  pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
+async function onPluginFileSelected(event: Event) {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (!file) return
+  const pluginId = pluginReleaseForm.pluginId || (releaseModalMode.value === 'edit' ? pluginReleaseForm.pluginId : '')
+  if (!pluginId) {
+    window.alert('请先选择插件,再上传安装包')
+    input.value = ''
+    return
+  }
+  try {
+    uploadingArtifact.value = true
+    const artifact = await api.uploadPluginArtifact(pluginId, file)
+    pluginReleaseForm.versionCode = artifact.versionCode
+    pluginReleaseForm.versionName = artifact.versionName
+    pluginReleaseForm.uploadedFileName = artifact.uploadedFileName
+    pluginReleaseForm.downloadUrl = artifact.downloadUrl
+  } catch (error) {
+    window.alert(error instanceof Error ? error.message : '插件包上传失败')
+    input.value = ''
+  } finally {
+    uploadingArtifact.value = false
+  }
+}
+
+function downloadFile(url: string) {
+  window.open(url, '_blank', 'noopener')
 }
 }
 
 
 async function publishFull(releaseId: string) {
 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 (selectedPlugin.value) pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id)
+  if (!selectedApp.value) return
+  if (tab.value === 'packages') {
+    appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
+  } else {
+    await refreshPluginReleaseRows()
+  }
 }
 }
 
 
 function prepareGray(releaseId: string) {
 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() })

+ 0 - 66
frontend/ops-platform/src/views/RegisterView.vue

@@ -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>

+ 0 - 293
frontend/ops-platform/src/views/VersionManagementView.vue

@@ -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>

+ 1 - 0
server/pom.xml

@@ -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>

+ 6 - 0
server/version-management-service/pom.xml

@@ -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>

+ 8 - 8
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/CompatibilityUpdateController.java

@@ -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(required = false) String userId
+        @RequestParam("packageName") String packageName,
+        @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(required = false) String userId,
-        @RequestParam(required = false) Integer hostVersionCode
+        @RequestParam("packageName") String packageName,
+        @RequestParam(value = "userId", required = false) String userId,
+        @RequestParam(value = "hostVersionCode", required = false) Integer hostVersionCode
     ) {
     ) {
         PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode);
         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(required = false) String userId,
-        @RequestParam long hostVersionCode
+        @RequestParam("appPackageName") String appPackageName,
+        @RequestParam(value = "userId", required = false) String userId,
+        @RequestParam("hostVersionCode") long hostVersionCode
     ) {
     ) {
         return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, hostVersionCode));
         return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, hostVersionCode));
     }
     }

+ 99 - 1
server/version-management-service/src/main/java/com/xuqm/versionmanagement/controller/OpsVersionController.java

@@ -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();
+    }
 }
 }

+ 157 - 0
server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/ReleaseArtifactService.java

@@ -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
+    ) {
+    }
+}

+ 48 - 1
server/version-management-service/src/main/java/com/xuqm/versionmanagement/service/VersionManagementService.java

@@ -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,

+ 8 - 0
server/version-management-service/src/main/resources/application.yml

@@ -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}