fix: make case material downloads deployment-safe

dev-QQq^2
陈沅 5 days ago
parent c05ea5ed3f
commit 4cb3b8756b

@ -25,24 +25,44 @@ const material = computed(() => {
const name = String(props.materialName || "").trim(); const name = String(props.materialName || "").trim();
const url = String(props.materialUrl || "").trim(); const url = String(props.materialUrl || "").trim();
if (!name || !url || (name === "案例资料.rar" && url === "/file/example.rar")) return null; 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 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 isImage = computed(() => ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(extension.value));
const isPdf = computed(() => extension.value === "pdf"); const isPdf = computed(() => extension.value === "pdf");
const previewable = computed(() => isImage.value || isPdf.value); 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; } if (!material.value?.url) { ElMessage.warning("暂无案例资料"); return; }
const link = document.createElement("a"); try {
link.href = material.value.url; const response = await fetch(material.value.url);
link.target = "_blank"; if (!response.ok) throw new Error(`HTTP ${response.status}`);
link.rel = "noopener"; const objectUrl = URL.createObjectURL(await response.blob());
link.download = material.value.name; const link = document.createElement("a");
document.body.appendChild(link); link.href = objectUrl;
link.click(); link.download = material.value.name;
document.body.removeChild(link); document.body.appendChild(link);
if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。"); link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.warn("Case material download failed", error);
ElMessage.error("案例资料文件不存在或暂不可下载,请联系管理员重新上传。");
}
} }
</script> </script>

@ -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 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 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 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 = [ const directStudentBriefFiles = [
"src/views/demand/index.vue", "src/views/demand/index.vue",
"src/views/demand/environment.vue", "src/views/demand/environment.vue",
@ -47,6 +48,35 @@ assert(
/TrainingMaterialButton/.test(productDevelopmentFactorsPage), /TrainingMaterialButton/.test(productDevelopmentFactorsPage),
"product development factors page should render the case material button" "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]; const studentBriefPages = [studentPage, newProductSurveyPage, productDevelopmentFactorsPage, trainingIntro, ...directStudentBriefPages];
assert(/实训背景/.test(trainingTaskBrief) && /实训目标/.test(trainingTaskBrief) && /实训要求/.test(trainingTaskBrief), "common TrainingTaskBrief component should render the three student brief sections"); assert(/实训背景/.test(trainingTaskBrief) && /实训目标/.test(trainingTaskBrief) && /实训要求/.test(trainingTaskBrief), "common TrainingTaskBrief component should render the three student brief sections");

Loading…
Cancel
Save