feat(software-version): 安装包改浏览器直传 MinIO,支持批量删除

- 后端新增 /api/admin/version/presign、confirm、delete 端点;直传后服务端校验对象并写 web_config
- OSS 层新增软件版本对象 presign PUT/大小校验/删除与 client 桶 URL 识别
- 后台前端上传改 presign→PUT→confirm 三段式直传,带进度条;成功提示精简为"发布成功"
- 版本列表加勾选/表头全选、批量删除与单行删除;操作按钮样式对齐其他子菜单
This commit is contained in:
2026-09-08 23:51:18 +08:00
parent 5fad0ae027
commit 45fdd8a294
9 changed files with 440 additions and 30 deletions
@@ -5,7 +5,7 @@ import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
import type { SoftwareVersionItem } from './version-dto.ts'
import { isNonEmptyVersion } from './version-dto.ts'
@@ -29,8 +29,53 @@ watch(filteredItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
})
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAllPage() {
const pageRows = pagedVersions.value
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
const next = new Set(selectedIds.value)
if (allOn) pageRows.forEach((row) => next.delete(row.id))
else pageRows.forEach((row) => next.add(row.id))
selectedIds.value = next
}
async function removeSelected() {
if (!selectedCount.value) return
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
const sample = rows.slice(0, 3).map((row) => row.version).join('、')
const summary = rows.length > 3 ? `${sample}${rows.length}` : sample
if (!window.confirm(`确认删除选中的 ${rows.length} 个版本(${summary})?此操作不可恢复。`)) return
try {
await deleteSoftwareVersions(Array.from(selectedIds.value))
ElMessage.success(`已删除 ${rows.length} 个版本`)
selectedIds.value = new Set()
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function removeOne(row: SoftwareVersionItem) {
if (!window.confirm(`确认删除版本 ${row.version}?此操作不可恢复。`)) return
try {
await deleteSoftwareVersions([row.id])
ElMessage.success('删除成功')
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const newVersion = ref('')
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
@@ -63,6 +108,7 @@ function openUpload() {
async function submitUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
uploadPercent.value = 0
if (!isNonEmptyVersion(newVersion.value)) {
uploadMsg.value = '请填写版本号'
return
@@ -75,16 +121,23 @@ async function submitUpload() {
uploadMsg.value = '仅支持 .zip 格式'
return
}
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true
try {
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
const version = created?.version || newVersion.value.trim()
const link = created?.fileUrl
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
// 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
uploadPercent.value = p
uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
})
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
uploadMsg.value = '发布成功'
uploadMsgOk.value = true
newVersion.value = ''
pickedFile.value = null
uploadPercent.value = 0
load()
} catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
@@ -103,6 +156,7 @@ onMounted(load)
<h3>版本列表</h3>
<div class="version-head-tools">
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
<button class="btn" type="button" @click="openUpload">上传新版本</button>
</div>
</div>
@@ -111,32 +165,39 @@ onMounted(load)
<table>
<thead>
<tr>
<th style="width: 120px">版本号</th>
<th style="width: 40px" class="th-select">
<input type="checkbox" :checked="pagedVersions.length > 0 && pagedVersions.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedVersions.length" />
</th>
<th style="width: 110px">版本号</th>
<th>下载链接</th>
<th style="width: 160px">创建时间</th>
<th style="width: 120px">操作</th>
<th style="width: 150px">创建时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredItems.length">
<tr v-for="row in pagedVersions" :key="row.version + row.id">
<tr v-for="row in pagedVersions" :key="row.version + row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
<td class="td-select">
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
</td>
<td>{{ row.version }}</td>
<td>
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
<td class="ops-cell">
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="4" class="empty-tip">加载中...</td>
<td colspan="5" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
</tr>
</tbody>
</table>
@@ -157,6 +218,7 @@ onMounted(load)
<p class="zip-hint">仅支持 .zip 格式</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
</el-form-item>
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
</el-form>
<template #footer>
@@ -276,7 +338,7 @@ h3 {
table-layout: fixed;
}
.version-table-scroll > table {
min-width: 760px;
min-width: 840px;
}
.table-scroll th,
.table-scroll td {
@@ -338,6 +400,36 @@ h3 {
color: #5b6f83;
font-size: 12px;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.row-selected td {
background: #fdf0ee;
}
.th-select,
.td-select {
text-align: center;
padding-left: 4px !important;
padding-right: 4px !important;
}
.th-select input,
.td-select input {
cursor: pointer;
accent-color: #b33a2e;
}
.upload-progress {
margin: 2px 0 10px;
}
.upload-msg {
margin-bottom: 8px;
}
@@ -1,21 +1,83 @@
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
import { http } from '@/api/http'
import axios from 'axios'
import { http, unwrap } from '@/api/http'
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
export const SOFTWARE_VERSION_PRESIGN_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/presign`
export const SOFTWARE_VERSION_CONFIRM_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/confirm`
export const SOFTWARE_VERSION_DELETE_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/delete`
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
return parseSoftwareVersionList(data)
}
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> {
const form = new FormData()
form.append('version', version)
form.append('file', file, filename || (file instanceof File ? file.name : 'version.zip'))
const { data } = await http.post<unknown>(SOFTWARE_VERSION_UPLOAD_ENDPOINT, form)
export interface SoftwareVersionUploadTarget {
uploadUrl: string
fileUrl: string
version: string
}
export interface SoftwareVersionUploadResult {
version: string
fileUrl: string
item: SoftwareVersionItem | null
}
/** 第一步:向后端申请直传 PUT 预签名地址与公开下载 URL(登录会话签发,3 分钟有效)。 */
export async function requestSoftwareVersionPresign(version: string): Promise<SoftwareVersionUploadTarget> {
const { data } = await http.post<unknown>(SOFTWARE_VERSION_PRESIGN_ENDPOINT, null, { params: { version } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
if (!uploadUrl) {
throw new Error('后端未返回直传地址,请重试')
}
return {
uploadUrl,
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
version: typeof core.version === 'string' ? core.version : version.trim(),
}
}
/** 第三步:直传完成后通知后端校验对象并写入版本记录(data.item 为新版本行)。 */
export async function confirmSoftwareVersion(version: string): Promise<SoftwareVersionItem | null> {
const { data } = await http.post<unknown>(SOFTWARE_VERSION_CONFIRM_ENDPOINT, null, { params: { version } })
return parseSoftwareVersionUpload(data)
}
/** 按 id 批量删除版本(后端会顺带清理无引用对象的 MinIO 安装包);返回实际删除行数。 */
export async function deleteSoftwareVersions(ids: number[]): Promise<number> {
if (!ids.length) return 0
const { data } = await http.post<unknown>(SOFTWARE_VERSION_DELETE_ENDPOINT, ids)
const core = (unwrap(data) ?? {}) as Record<string, unknown>
return typeof core.deleted === 'number' ? core.deleted : ids.length
}
/**
* 上传客户端软件版本(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
*/
export async function uploadSoftwareVersion(
version: string,
file: Blob,
_filename = '',
onProgress?: (percent: number) => void,
): Promise<SoftwareVersionUploadResult> {
const target = await requestSoftwareVersionPresign(version)
await directPut.put(target.uploadUrl, file, {
headers: { 'Content-Type': 'application/octet-stream' },
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total || 0
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
const item = await confirmSoftwareVersion(target.version)
return { version: target.version, fileUrl: item?.fileUrl || target.fileUrl, item }
}
@@ -9,8 +9,8 @@ test('align_software_version_page_wiring', () => {
// 下载按钮:<a download> 强制下载(对齐 admin.js:6301)。
assert.match(page, /download/, '下载链接带 download 强制下载')
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
// 上传成功:弹窗保持打开,成功文案留驻可复制(对齐 admin.js:6484-6488)。
assert.match(page, /发布成功。版本:/, '成功文案留驻弹窗')
// 上传成功:弹窗保持打开,成功提示为“发布成功”(对齐新版精简提示)。
assert.match(page, /发布成功/, '成功提示留驻弹窗')
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
// 上传弹窗文案(对齐 admin.html:6094-6110)。
assert.match(page, /上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。/, '弹窗描述对齐')
+4 -2
View File
@@ -56,11 +56,13 @@ test('test_task_128_version_upload_invalid_input_rejected', () => {
})
test('test_task_128_version_upload_dependency_failure_returns_actionable_message', () => {
// 依赖失败/提交走 adapter:上传表单纯逻辑;POST /api/admin/version multipart
// 依赖失败/提交走 adapter:上传表单纯逻辑;浏览器直传(presign 签发 → PUT 到 MinIO)由 version-api 编排
const mod = readSource('src/pages/records/version-upload-model.ts')
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
const api = readSource('src/pages/records/version-api.ts')
assert.match(api, /uploadSoftwareVersion/)
assert.match(api, /\/api\/admin\/version/)
assert.match(api, /FormData/)
// 直传改造后不再走 multipart FormData,改 presign 预签名地址 + 独立 PUT 直传 MinIO。
assert.match(api, /presign/)
assert.match(api, /\.put\(/)
})
+3 -3
View File
@@ -62,10 +62,10 @@ test('test_task_260_version_invalid_input_rejected', () => {
})
test('test_task_260_version_dependency_failure_returns_actionable_message', () => {
// 依赖失败:成功文案含版本与链接;上传经 API 适配并解析新版本;空态兜底。
// 依赖失败:成功提示精简为“发布成功”;上传经 API 适配并解析新版本;空态兜底。
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
assert.match(page, /发布成功。版本:/, '成功文案需含版本')
assert.match(page, /,链接:/, '成功文案需含链接')
assert.match(page, /发布成功/, '成功提示留驻弹窗')
assert.match(page, /正在上传/, '上传过程提示含进度')
assert.match(page, /暂无版本/, '空态文案')
const api = readSource('src/pages/records/version-api.ts')
assert.match(api, /parseSoftwareVersionUpload/, '上传适配解析新版本行')