Files
crawler-plugin/admin-frontend-vue/src/pages/records/RecordsSoftwareVersionPage.vue
T
huangzd1997 45fdd8a294 feat(software-version): 安装包改浏览器直传 MinIO,支持批量删除
- 后端新增 /api/admin/version/presign、confirm、delete 端点;直传后服务端校验对象并写 web_config
- OSS 层新增软件版本对象 presign PUT/大小校验/删除与 client 桶 URL 识别
- 后台前端上传改 presign→PUT→confirm 三段式直传,带进度条;成功提示精简为"发布成功"
- 版本列表加勾选/表头全选、批量删除与单行删除;操作按钮样式对齐其他子菜单
2026-09-08 23:51:18 +08:00

440 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
import type { SoftwareVersionItem } from './version-dto.ts'
import { isNonEmptyVersion } from './version-dto.ts'
const loading = ref(false)
const items = ref<SoftwareVersionItem[]>([])
/** 版本号搜索(客户端过滤)。 */
const versionKeyword = ref('')
const filteredItems = computed(() => {
const kw = (versionKeyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
})
/** 全站分页统一:版本列表客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changeVersionPage(p: number) { page.value = p }
function changeVersionSize(size: number) { pageSize.value = size; page.value = 1 }
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
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 留驻)。 */
const uploadMsg = ref('')
const uploadMsgOk = ref(false)
async function load() {
loading.value = true
try {
items.value = (await fetchSoftwareVersions()).items
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
} finally {
loading.value = false
}
}
function onFileChange(file: File) {
pickedFile.value = file
}
function openUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
newVersion.value = ''
pickedFile.value = null
uploadVisible.value = true
}
async function submitUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
uploadPercent.value = 0
if (!isNonEmptyVersion(newVersion.value)) {
uploadMsg.value = '请填写版本号'
return
}
if (!pickedFile.value) {
uploadMsg.value = '请选择 zip 压缩包'
return
}
if (!/\.zip$/i.test(pickedFile.value.name)) {
uploadMsg.value = '仅支持 .zip 格式'
return
}
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true
try {
// 浏览器直传 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 : '上传失败'
} finally {
uploading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="version-view">
<section class="panel-box">
<div class="version-head">
<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>
<div class="table-scroll version-table-scroll">
<table>
<thead>
<tr>
<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: 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" :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 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="5" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changeVersionPage" @size-change="changeVersionSize" />
</section>
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
<p class="upload-desc">上传软件 ZIP 系统会自动计算 MD5 并存储到 OSS</p>
<el-form label-width="110px">
<el-form-item label="版本号" required>
<el-input v-model="newVersion" placeholder="例如:1.0.0" />
</el-form-item>
<el-form-item label="ZIP 压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
<el-button>选择文件</el-button>
</el-upload>
<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>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">上传软件版本</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-version(蓝白末层)。 */
.version-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.version-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.version-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.version-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.version-keyword:hover {
border-color: #9fb7cd;
}
.version-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.version-table-scroll > table {
min-width: 840px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.link-cell {
display: block;
color: #2f5d8b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.link-cell:hover {
text-decoration: underline;
}
.dl-btn {
text-decoration: none;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
.zip-hint {
margin: 4px 0 0;
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;
}
.upload-msg :deep(.el-alert__title) {
word-break: break-all;
}
</style>