更新软件升级

This commit is contained in:
2026-04-08 20:33:05 +08:00
parent 1bfa019634
commit a1f04f94ec
14 changed files with 1658 additions and 11 deletions
@@ -0,0 +1,463 @@
<template>
<el-drawer
v-model="visible"
:title="isAdd ? '新增软件产品' : '编辑软件产品'"
size="560px"
destroy-on-close
@closed="onClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" v-loading="loading">
<el-form-item label="软件名称" prop="name">
<el-input v-model="form.name" placeholder="如:桌面客户端" />
</el-form-item>
<el-form-item label="产品 code" prop="code">
<el-input
v-model="form.code"
placeholder="客户端检查更新时传的 code,唯一"
:disabled="!isAdd"
/>
</el-form-item>
<el-form-item label="最新版本号" prop="latestVersion">
<el-input v-model="form.latestVersion" placeholder="如 1.2.0" />
</el-form-item>
<el-form-item label="安装包">
<el-upload
ref="uploadRef"
class="pkg-upload"
:limit="1"
:file-list="displayFileList"
:http-request="handlePackageUpload"
:on-remove="onRemovePackage"
@exceed="onExceedReplace"
accept=".zip,.exe,.dmg,.msi,.msix,.apk,.deb,.rpm,.7z,.pkg,.tar,.gz"
>
<el-button type="primary" :loading="uploading">
{{ form.fileId ? "重新上传安装包" : "上传安装包" }}
</el-button>
</el-upload>
<div v-if="uploadXHRActive" class="upload-progress-wrap">
<el-progress
:percentage="uploadPercent"
:indeterminate="uploadIndeterminate"
:stroke-width="10"
striped
striped-flow
/>
<div class="upload-progress-text">
<template v-if="!uploadIndeterminate">
<span>{{ uploadPercent }}%</span>
<span class="upload-sep">·</span>
<span>{{ uploadSpeedText }}</span>
<template v-if="uploadSizeText">
<span class="upload-sep">·</span>
<span>{{ uploadSizeText }}</span>
</template>
</template>
<template v-else>
<span>上传中</span>
<template v-if="uploadSpeedText && uploadSpeedText !== '—'">
<span class="upload-sep">·</span>
<span>{{ uploadSpeedText }}</span>
</template>
<template v-if="uploadSizeText">
<span class="upload-sep">·</span>
<span>{{ uploadSizeText }}</span>
</template>
</template>
</div>
</div>
<div v-if="form.fileId" class="pkg-meta">
<span>文件 ID<code>{{ form.fileId }}</code></span>
</div>
</el-form-item>
<el-form-item label="下载/更新地址">
<el-input
v-model="form.downloadUrl"
type="textarea"
:rows="2"
placeholder="上传后自动填写;可改为 CDN 等完整 URL"
/>
</el-form-item>
<el-form-item label="强制更新">
<el-switch v-model="form.forceUpdate" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="更新说明">
<el-input v-model="form.releaseNotes" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button :value="1">启用</el-radio-button>
<el-radio-button :value="0">停用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item v-if="!isAdd && resolvedUrl" label="当前解析地址">
<el-link :href="resolvedUrl" type="primary" target="_blank">{{ resolvedUrl }}</el-link>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref, reactive, nextTick } from "vue";
import { ElMessage } from "element-plus";
import {
getSoftwareUpgradeDetail,
createSoftwareUpgrade,
updateSoftwareUpgrade,
} from "@/api/softwareUpgrade";
import { getUserCate, createFileCate, uploadFile, getFileById } from "@/api/file";
const emit = defineEmits(["saved"]);
const visible = ref(false);
const loading = ref(false);
const saving = ref(false);
const uploading = ref(false);
const uploadXHRActive = ref(false);
const uploadPercent = ref(0);
const uploadIndeterminate = ref(false);
const uploadSpeedText = ref("—");
const uploadSizeText = ref("");
const isAdd = ref(false);
const resolvedUrl = ref("");
const formRef = ref(null);
const uploadRef = ref(null);
const displayFileList = ref([]);
const uploadedLabel = ref("");
const appsUpgradeCateId = ref(null);
const form = reactive({
id: 0,
name: "",
code: "",
latestVersion: "0.0.1",
fileId: null,
downloadUrl: "",
forceUpdate: 0,
releaseNotes: "",
status: 1,
sort: 0,
});
const rules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
code: [{ required: true, message: "请输入产品标识", trigger: "blur" }],
latestVersion: [{ required: true, message: "请输入版本号", trigger: "blur" }],
};
function formatBytes(n) {
if (n == null || !Number.isFinite(n) || n < 0) return "";
if (n < 1024) return `${Math.round(n)} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 2 : 1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(2)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/** @param {number} bps bytes per second */
function formatSpeed(bps) {
if (bps == null || !Number.isFinite(bps) || bps <= 0) return "—";
return `${formatBytes(bps)}/s`;
}
function absoluteFromSrc(src) {
if (!src) return "";
const s = String(src).trim();
if (/^https?:\/\//i.test(s)) return s;
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
const path = s.startsWith("/") ? s : `/${s}`;
return base ? `${base}${path}` : path;
}
async function ensureAppsUpgradeCate() {
if (appsUpgradeCateId.value != null) {
return appsUpgradeCateId.value;
}
const res = await getUserCate();
if (res?.code !== 200 || !Array.isArray(res.data)) {
return null;
}
const found = res.data.find(
(c) => String(c.name || "").trim().toLowerCase() === "appsupgrade"
);
if (found && found.id) {
appsUpgradeCateId.value = Number(found.id);
return appsUpgradeCateId.value;
}
const cr = await createFileCate({ name: "appsupgrade" });
if (cr?.code === 200 && cr.data?.id != null) {
appsUpgradeCateId.value = Number(cr.data.id);
return appsUpgradeCateId.value;
}
ElMessage.error(cr?.msg || "创建文件分组 appsupgrade 失败");
return null;
}
function reset() {
form.id = 0;
form.name = "";
form.code = "";
form.latestVersion = "0.0.1";
form.fileId = null;
form.downloadUrl = "";
form.forceUpdate = 0;
form.releaseNotes = "";
form.status = 1;
form.sort = 0;
resolvedUrl.value = "";
displayFileList.value = [];
uploadedLabel.value = "";
uploadXHRActive.value = false;
uploadPercent.value = 0;
uploadIndeterminate.value = false;
uploadSpeedText.value = "—";
uploadSizeText.value = "";
uploadRef.value?.clearFiles?.();
}
function onClosed() {
reset();
}
function onRemovePackage() {
form.fileId = null;
form.downloadUrl = "";
uploadedLabel.value = "";
displayFileList.value = [];
}
/** Element Plus:超出 limit 时传入待加入的文件列表(一般为 File[]) */
function onExceedReplace(files) {
const first = files[0];
const raw = first?.raw != null ? first.raw : first;
uploadRef.value?.clearFiles?.();
form.fileId = null;
form.downloadUrl = "";
uploadedLabel.value = "";
displayFileList.value = [];
if (raw) {
handlePackageUpload({ file: raw, onSuccess: () => {}, onError: () => {} });
}
}
async function handlePackageUpload(options) {
const file = options.file?.raw != null ? options.file.raw : options.file;
if (!file) {
options.onError?.(new Error("无文件"));
return;
}
uploading.value = true;
try {
const cateId = await ensureAppsUpgradeCate();
if (!cateId) {
options.onError?.(new Error("no category"));
return;
}
const fd = new FormData();
fd.append("file", file);
uploadXHRActive.value = true;
uploadPercent.value = 0;
uploadIndeterminate.value = false;
uploadSpeedText.value = "—";
uploadSizeText.value = "";
const xhrStart = Date.now();
let lastLoaded = 0;
let lastTick = xhrStart;
let emaBps = 0;
const res = await uploadFile(fd, {
cate: cateId,
onUploadProgress: (e) => {
const now = Date.now();
const loaded = e.loaded;
const total = e.total ?? 0;
const elapsedSec = (now - xhrStart) / 1000;
const dt = (now - lastTick) / 1000;
if (dt >= 0.07 && loaded >= lastLoaded) {
const inst = (loaded - lastLoaded) / dt;
emaBps = emaBps > 0 ? emaBps * 0.72 + inst * 0.28 : inst;
lastLoaded = loaded;
lastTick = now;
}
const avgBps = elapsedSec > 0.12 ? loaded / elapsedSec : 0;
const showBps = emaBps > 0 ? emaBps : avgBps;
uploadSpeedText.value = showBps > 0 ? formatSpeed(showBps) : elapsedSec > 0.05 ? formatSpeed(avgBps) : "—";
if (total > 0) {
uploadIndeterminate.value = false;
uploadPercent.value = Math.min(100, Math.round((loaded * 100) / total));
uploadSizeText.value = `${formatBytes(loaded)} / ${formatBytes(total)}`;
} else {
uploadIndeterminate.value = true;
uploadSizeText.value = loaded > 0 ? `已上传 ${formatBytes(loaded)}` : "";
}
},
});
if (res?.code === 200 || res?.code === 201) {
const d = res.data || {};
form.fileId = d.id != null ? Number(d.id) : null;
const src = d.url || "";
form.downloadUrl = absoluteFromSrc(src);
uploadedLabel.value = d.name || file.name || "安装包";
displayFileList.value = [{ name: uploadedLabel.value, uid: `pkg-${form.fileId}-${Date.now()}` }];
ElMessage.success(res.code === 201 ? "文件已存在,已关联" : "上传成功");
options.onSuccess?.(res);
} else {
ElMessage.error(res?.msg || "上传失败");
options.onError?.(new Error(res?.msg));
}
} catch (e) {
ElMessage.error(e?.message || "上传失败");
options.onError?.(e);
} finally {
uploading.value = false;
uploadXHRActive.value = false;
uploadPercent.value = 0;
uploadIndeterminate.value = false;
uploadSpeedText.value = "—";
uploadSizeText.value = "";
}
}
async function open(id) {
reset();
isAdd.value = !id;
visible.value = true;
appsUpgradeCateId.value = null;
await nextTick();
formRef.value?.clearValidate?.();
if (!id) return;
loading.value = true;
try {
const res = await getSoftwareUpgradeDetail(id);
if (res?.code !== 200 || !res.data) {
ElMessage.error(res?.msg || "加载失败");
visible.value = false;
return;
}
const d = res.data;
form.id = d.id;
form.name = d.name || "";
form.code = d.code || "";
form.latestVersion = d.latestVersion || "0.0.1";
form.fileId = d.fileId != null ? Number(d.fileId) : null;
form.downloadUrl = d.downloadUrl || d.resolvedDownloadUrl || "";
form.forceUpdate = d.forceUpdate ? 1 : 0;
form.releaseNotes = d.releaseNotes || "";
form.status = d.status !== 0 ? 1 : 0;
form.sort = d.sort ?? 0;
resolvedUrl.value = d.resolvedDownloadUrl || "";
if (form.fileId) {
try {
const fr = await getFileById(form.fileId);
if (fr?.code === 200 && fr.data) {
uploadedLabel.value = fr.data.name || `文件 #${form.fileId}`;
} else {
uploadedLabel.value = `文件 #${form.fileId}`;
}
} catch {
uploadedLabel.value = `文件 #${form.fileId}`;
}
displayFileList.value = [
{ name: uploadedLabel.value, uid: `exist-${form.fileId}` },
];
}
} finally {
loading.value = false;
}
}
async function submit() {
try {
await formRef.value?.validate();
} catch {
return;
}
const payload = {
name: form.name.trim(),
code: form.code.trim(),
latestVersion: form.latestVersion.trim(),
downloadUrl: form.downloadUrl.trim() || undefined,
forceUpdate: form.forceUpdate,
releaseNotes: form.releaseNotes.trim() || undefined,
status: form.status,
sort: form.sort,
};
if (form.fileId != null && form.fileId !== "" && Number(form.fileId) > 0) {
payload.fileId = Number(form.fileId);
} else {
payload.fileId = 0;
}
saving.value = true;
try {
let res;
if (isAdd.value) {
res = await createSoftwareUpgrade(payload);
} else {
res = await updateSoftwareUpgrade(form.id, payload);
}
if (res?.code === 200) {
ElMessage.success("已保存");
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "失败");
}
} finally {
saving.value = false;
}
}
defineExpose({ open });
</script>
<style scoped>
.hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.55;
}
.hint code {
padding: 0 4px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.pkg-upload {
width: 100%;
}
.upload-progress-wrap {
margin-top: 10px;
width: 100%;
}
.upload-progress-text {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
word-break: break-all;
}
.upload-progress-text .upload-sep {
margin: 0 0.35em;
opacity: 0.55;
}
.pkg-meta {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.pkg-meta code {
font-size: 12px;
}
</style>