463 lines
14 KiB
Vue
463 lines
14 KiB
Vue
<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, getFileById } from "@/api/file";
|
||
import { smartUpload } from "@/utils/qiniuUpload";
|
||
|
||
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;
|
||
}
|
||
|
||
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 result = await smartUpload(file, {
|
||
cate: cateId,
|
||
onProgress: (progress) => {
|
||
const now = Date.now();
|
||
const loaded = progress.loaded;
|
||
const total = progress.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)}` : "";
|
||
}
|
||
},
|
||
});
|
||
|
||
// 上传成功
|
||
form.fileId = result.id;
|
||
form.downloadUrl = result.url;
|
||
uploadedLabel.value = result.name || file.name || "安装包";
|
||
displayFileList.value = [{ name: uploadedLabel.value, uid: `pkg-${form.fileId}-${Date.now()}` }];
|
||
ElMessage.success("上传成功");
|
||
options.onSuccess?.(result);
|
||
} 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>
|