From 4cb3b8756b692b91c737b4c92de3b0a87e8d4d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B2=85?= <907037276@qq.com> Date: Tue, 25 Aug 2026 18:03:35 +0800 Subject: [PATCH] fix: make case material downloads deployment-safe --- .../components/TrainingMaterialButton.vue | 42 ++++++++++++++----- tests/training-material.static.test.cjs | 30 +++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/views/components/TrainingMaterialButton.vue b/src/views/components/TrainingMaterialButton.vue index a56d29f..1dd4ee0 100644 --- a/src/views/components/TrainingMaterialButton.vue +++ b/src/views/components/TrainingMaterialButton.vue @@ -25,24 +25,44 @@ const material = computed(() => { const name = String(props.materialName || "").trim(); const url = String(props.materialUrl || "").trim(); if (!name || !url || (name === "案例资料.rar" && url === "/file/example.rar")) return null; - return { name, url }; + return { name, url: normalizeMaterialUrl(url) }; }); const extension = computed(() => String(material.value?.name || material.value?.url || "").split(/[?#]/)[0].split(".").pop().toLowerCase()); const isImage = computed(() => ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(extension.value)); const isPdf = computed(() => extension.value === "pdf"); const previewable = computed(() => isImage.value || isPdf.value); -function handleDownload() { +function normalizeMaterialUrl(rawUrl) { + const url = String(rawUrl || "").trim(); + if (!url) return ""; + try { + const parsed = new URL(url); + if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname) && parsed.pathname.startsWith("/file/")) { + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch { + // Relative file paths are already resolved through the current deployment or Vite proxy. + } + return url; +} + +async function handleDownload() { if (!material.value?.url) { ElMessage.warning("暂无案例资料"); return; } - const link = document.createElement("a"); - link.href = material.value.url; - link.target = "_blank"; - link.rel = "noopener"; - link.download = material.value.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。"); + try { + const response = await fetch(material.value.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const objectUrl = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = material.value.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + } catch (error) { + console.warn("Case material download failed", error); + ElMessage.error("案例资料文件不存在或暂不可下载,请联系管理员重新上传。"); + } } diff --git a/tests/training-material.static.test.cjs b/tests/training-material.static.test.cjs index 2475296..43afaaa 100644 --- a/tests/training-material.static.test.cjs +++ b/tests/training-material.static.test.cjs @@ -8,6 +8,7 @@ const studentPage = fs.readFileSync(path.join(root, "src/views/training/GenericT const newProductSurveyPage = fs.readFileSync(path.join(root, "src/views/foundation/new-product-survey.vue"), "utf8"); const productDevelopmentFactorsPage = fs.readFileSync(path.join(root, "src/views/foundation/product-development-factors.vue"), "utf8"); const trainingIntro = fs.readFileSync(path.join(root, "src/views/components/TrainingIntro.vue"), "utf8"); +const materialButton = fs.readFileSync(path.join(root, "src/views/components/TrainingMaterialButton.vue"), "utf8"); const directStudentBriefFiles = [ "src/views/demand/index.vue", "src/views/demand/environment.vue", @@ -47,6 +48,35 @@ assert( /TrainingMaterialButton/.test(productDevelopmentFactorsPage), "product development factors page should render the case material button" ); +assert.match( + materialButton, + /function normalizeMaterialUrl\(rawUrl\)/, + "case material downloads must normalize legacy absolute URLs" +); +assert.ok( + materialButton.includes('["localhost", "127.0.0.1", "::1"]'), + "localhost case material URLs must not be sent to each student's own browser port" +); +assert.match( + materialButton, + /url: normalizeMaterialUrl\(url\)/, + "the download button must use the normalized material URL" +); +assert.match( + materialButton, + /async function handleDownload\(\)/, + "case material downloads must validate the file response before opening a download" +); +assert.match( + materialButton, + /await fetch\(material\.value\.url\)/, + "case material downloads must request the configured file URL" +); +assert.match( + materialButton, + /案例资料文件不存在或暂不可下载/, + "a missing case material file must show a clear recovery message" +); const studentBriefPages = [studentPage, newProductSurveyPage, productDevelopmentFactorsPage, trainingIntro, ...directStudentBriefPages]; assert(/实训背景/.test(trainingTaskBrief) && /实训目标/.test(trainingTaskBrief) && /实训要求/.test(trainingTaskBrief), "common TrainingTaskBrief component should render the three student brief sections");