VersionManagementView.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <template>
  2. <section class="page stack">
  3. <div class="panel">
  4. <div class="section-head">
  5. <div>
  6. <p class="section-tag">业务模块</p>
  7. <h2>版本管理</h2>
  8. </div>
  9. <button class="secondary" @click="loadAll">刷新</button>
  10. </div>
  11. <div v-for="item in applications" :key="item.application.id" class="app-card">
  12. <div class="app-card__top">
  13. <div>
  14. <h3>{{ item.application.name }}</h3>
  15. <p class="muted">{{ item.application.packageName }}</p>
  16. <p class="chips">
  17. <span v-for="module in item.application.businessModules" :key="module">{{ module }}</span>
  18. </p>
  19. </div>
  20. <label class="toggle">
  21. <input
  22. type="checkbox"
  23. :checked="item.application.pluginManagementEnabled"
  24. @change="togglePlugin(item.application.id, ($event.target as HTMLInputElement).checked)"
  25. />
  26. 插件化管理
  27. </label>
  28. </div>
  29. <form class="form-grid" @submit.prevent="upload(item.application.id)">
  30. <label>
  31. 包类型
  32. <select v-model="uploadForms[item.application.id].packageType">
  33. <option value="APP">App</option>
  34. <option value="PLUGIN">插件</option>
  35. </select>
  36. </label>
  37. <label>
  38. 版本号
  39. <input v-model="uploadForms[item.application.id].versionName" placeholder="0.3.0" required />
  40. </label>
  41. <label>
  42. 版本码
  43. <input v-model.number="uploadForms[item.application.id].versionCode" type="number" min="1" required />
  44. </label>
  45. <label>
  46. 包文件名
  47. <input v-model="uploadForms[item.application.id].uploadedFileName" placeholder="app-release.apk" required />
  48. </label>
  49. <label class="full">
  50. 下载地址
  51. <input v-model="uploadForms[item.application.id].downloadUrl" placeholder="https://example.com/app.apk" required />
  52. </label>
  53. <label class="full">
  54. 更新标题
  55. <input v-model="uploadForms[item.application.id].title" placeholder="发现新版本" required />
  56. </label>
  57. <label class="full">
  58. 更新说明
  59. <textarea v-model="uploadForms[item.application.id].changelog" rows="3"></textarea>
  60. </label>
  61. <button class="primary">上传版本包</button>
  62. </form>
  63. <table class="table">
  64. <thead>
  65. <tr>
  66. <th>版本</th>
  67. <th>类型</th>
  68. <th>状态</th>
  69. <th>策略</th>
  70. <th>灰度目标</th>
  71. <th></th>
  72. </tr>
  73. </thead>
  74. <tbody>
  75. <tr v-for="release in item.releases" :key="release.id">
  76. <td>{{ release.versionName }} ({{ release.versionCode }})</td>
  77. <td>{{ release.packageType }}</td>
  78. <td>{{ release.status }}</td>
  79. <td>{{ release.publishStrategy }}</td>
  80. <td>{{ graySummary(release) }}</td>
  81. <td class="actions">
  82. <button class="ghost" @click="publishFull(item.application.id, release.id)">全量发布</button>
  83. <button class="ghost" @click="selectGrayRelease(item.application.id, release.id)">灰度发布</button>
  84. </td>
  85. </tr>
  86. </tbody>
  87. </table>
  88. </div>
  89. </div>
  90. <div class="panel">
  91. <div class="section-head">
  92. <div>
  93. <p class="section-tag">灰度发布</p>
  94. <h2>用户平台 Hook 选人</h2>
  95. </div>
  96. <p class="muted">用户数据已脱敏,可按分组、快速选择或单个用户圈定。</p>
  97. </div>
  98. <div class="filters">
  99. <label>
  100. 分组
  101. <select v-model="filters.groupCode" @change="loadUsers">
  102. <option value="">全部</option>
  103. <option v-for="group in groups" :key="group.code" :value="group.code">
  104. {{ group.name }} ({{ group.userCount }})
  105. </option>
  106. </select>
  107. </label>
  108. <label>
  109. 快速选择
  110. <select v-model="filters.quickSelectionCode" @change="loadUsers">
  111. <option value="">全部</option>
  112. <option v-for="item in quickSelections" :key="item.code" :value="item.code">
  113. {{ item.name }} ({{ item.userCount }})
  114. </option>
  115. </select>
  116. </label>
  117. <label class="grow">
  118. 搜索
  119. <input v-model="filters.keyword" placeholder="用户 ID / 昵称 / 地区" @input="loadUsers" />
  120. </label>
  121. </div>
  122. <div class="selected-bar">
  123. <span>当前灰度版本:{{ selectedReleaseId || '请先点一个版本的灰度发布' }}</span>
  124. <span>已选择用户:{{ selectedUsers.length }}</span>
  125. <button class="primary" :disabled="!selectedReleaseId" @click="publishGray">确认灰度发布</button>
  126. </div>
  127. <div class="chips">
  128. <button
  129. v-for="item in quickSelections"
  130. :key="item.code"
  131. class="chip-button"
  132. @click="applyQuickSelection(item.code)"
  133. >
  134. {{ item.name }}
  135. </button>
  136. </div>
  137. <table class="table">
  138. <thead>
  139. <tr>
  140. <th></th>
  141. <th>用户 ID</th>
  142. <th>昵称</th>
  143. <th>手机号</th>
  144. <th>邮箱</th>
  145. <th>地区</th>
  146. <th>分组</th>
  147. </tr>
  148. </thead>
  149. <tbody>
  150. <tr v-for="user in users" :key="user.id">
  151. <td>
  152. <input type="checkbox" :checked="selectedUsers.includes(user.id)" @change="toggleUser(user.id)" />
  153. </td>
  154. <td>{{ user.id }}</td>
  155. <td>{{ user.nickname }}</td>
  156. <td>{{ user.phone }}</td>
  157. <td>{{ user.email }}</td>
  158. <td>{{ user.region }}</td>
  159. <td>{{ user.groupName }}</td>
  160. </tr>
  161. </tbody>
  162. </table>
  163. </div>
  164. </section>
  165. </template>
  166. <script setup lang="ts">
  167. import { onMounted, reactive, ref } from 'vue'
  168. import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type QuickSelection, type ReleaseRecord } from '../api/client'
  169. const applications = ref<ApplicationDetail[]>([])
  170. const groups = ref<AudienceGroup[]>([])
  171. const quickSelections = ref<QuickSelection[]>([])
  172. const users = ref<AudienceUser[]>([])
  173. const selectedUsers = ref<string[]>([])
  174. const selectedAppId = ref('')
  175. const selectedReleaseId = ref('')
  176. const filters = reactive({
  177. keyword: '',
  178. groupCode: '',
  179. quickSelectionCode: '',
  180. })
  181. const uploadForms = reactive<Record<string, {
  182. packageType: 'APP' | 'PLUGIN'
  183. versionCode: number
  184. versionName: string
  185. title: string
  186. changelog: string
  187. downloadUrl: string
  188. uploadedFileName: string
  189. entryActivity: string
  190. forceUpdate: boolean
  191. }>>({})
  192. function ensureForm(appId: string) {
  193. if (!uploadForms[appId]) {
  194. uploadForms[appId] = {
  195. packageType: 'APP',
  196. versionCode: 1,
  197. versionName: '',
  198. title: '发现新版本',
  199. changelog: '',
  200. downloadUrl: '',
  201. uploadedFileName: '',
  202. entryActivity: 'com.xuqm.plugin.ui.PluginUiActivity',
  203. forceUpdate: false,
  204. }
  205. }
  206. }
  207. function graySummary(release: ReleaseRecord) {
  208. if (!release.grayRule) return '全量'
  209. const rule = release.grayRule
  210. return `组 ${rule.groupCodes.length} / 快选 ${rule.quickSelectionCodes.length} / 用户 ${rule.userIds.length}`
  211. }
  212. function selectGrayRelease(appId: string, releaseId: string) {
  213. selectedAppId.value = appId
  214. selectedReleaseId.value = releaseId
  215. }
  216. async function loadAll() {
  217. const [appList, groupList, quickList] = await Promise.all([
  218. api.listApplications(),
  219. api.listAudienceGroups(),
  220. api.listQuickSelections(),
  221. ])
  222. applications.value = appList
  223. groups.value = groupList
  224. quickSelections.value = quickList
  225. appList.forEach(item => ensureForm(item.application.id))
  226. await loadUsers()
  227. }
  228. async function loadUsers() {
  229. users.value = await api.listAudienceUsers(filters)
  230. }
  231. async function togglePlugin(appId: string, enabled: boolean) {
  232. await api.togglePluginManagement(appId, enabled)
  233. await loadAll()
  234. }
  235. async function upload(appId: string) {
  236. const form = uploadForms[appId]
  237. await api.uploadRelease(appId, form)
  238. await loadAll()
  239. }
  240. async function publishFull(appId: string, releaseId: string) {
  241. await api.publishRelease(appId, releaseId, { grayPublish: false })
  242. await loadAll()
  243. }
  244. function toggleUser(userId: string) {
  245. if (selectedUsers.value.includes(userId)) {
  246. selectedUsers.value = selectedUsers.value.filter(item => item !== userId)
  247. } else {
  248. selectedUsers.value = [...selectedUsers.value, userId]
  249. }
  250. }
  251. function applyQuickSelection(code: string) {
  252. filters.quickSelectionCode = code
  253. void loadUsers()
  254. }
  255. async function publishGray() {
  256. if (!selectedReleaseId.value || !selectedAppId.value) return
  257. await api.publishRelease(selectedAppId.value, selectedReleaseId.value, {
  258. grayPublish: true,
  259. hookName: 'user-platform-gray-hook',
  260. groupCodes: filters.groupCode ? [filters.groupCode] : [],
  261. quickSelectionCodes: filters.quickSelectionCode ? [filters.quickSelectionCode] : [],
  262. userIds: selectedUsers.value,
  263. })
  264. await loadAll()
  265. }
  266. onMounted(() => {
  267. void loadAll()
  268. })
  269. </script>