|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="material-actions">
|
|
|
|
|
|
<el-button v-if="caseMaterial && caseMaterial.previewable" class="material-button" @click="previewVisible = true">
|
|
|
|
|
|
<el-icon>
|
|
|
|
|
|
<View />
|
|
|
|
|
|
</el-icon>预览案例资料
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<!-- 教师端未上传的资料类型,学生端直接不显示对应按钮 -->
|
|
|
|
|
|
<el-button v-if="caseMaterial" class="material-button" @click="downloadMaterial(caseMaterial, '案例资料')">
|
|
|
|
|
|
<el-icon>
|
|
|
|
|
|
<Download />
|
|
|
|
|
|
</el-icon>案例资料
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="trainingData" class="material-button" @click="downloadMaterial(trainingData, '案例数据')">
|
|
|
|
|
|
<el-icon>
|
|
|
|
|
|
<Download />
|
|
|
|
|
|
</el-icon>案例数据
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="guideManual" class="material-button" @click="downloadMaterial(guideManual, '实训指导手册')">
|
|
|
|
|
|
<el-icon>
|
|
|
|
|
|
<Download />
|
|
|
|
|
|
</el-icon>实训指导手册
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="aiManual" class="material-button" @click="downloadMaterial(aiManual, 'AI辅助指导手册')">
|
|
|
|
|
|
<el-icon>
|
|
|
|
|
|
<Download />
|
|
|
|
|
|
</el-icon>AI辅助指导手册
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog v-model="previewVisible" :title="caseMaterial?.name || '案例资料预览'" width="min(920px, 92vw)" destroy-on-close
|
|
|
|
|
|
append-to-body>
|
|
|
|
|
|
<div v-if="isImage" class="image-preview"><img :src="caseMaterial.url" :alt="caseMaterial.name" /></div>
|
|
|
|
|
|
<iframe v-else-if="isPdf" class="pdf-preview" :src="caseMaterial.url" :title="caseMaterial.name"></iframe>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed, ref } from "vue"
|
|
|
|
|
|
import { ElMessage } from "element-plus"
|
|
|
|
|
|
import { Download, View } from "@element-plus/icons-vue"
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
// 案例资料(向后兼容旧 materialName / materialUrl)
|
|
|
|
|
|
materialName: { type: String, default: "" },
|
|
|
|
|
|
materialUrl: { type: String, default: "" },
|
|
|
|
|
|
caseMaterialName: { type: String, default: "" },
|
|
|
|
|
|
caseMaterialUrl: { type: String, default: "" },
|
|
|
|
|
|
// 案例数据(内部字段沿用 trainingData 命名)
|
|
|
|
|
|
trainingDataName: { type: String, default: "" },
|
|
|
|
|
|
trainingDataUrl: { type: String, default: "" },
|
|
|
|
|
|
// 实训指导手册
|
|
|
|
|
|
guideManualName: { type: String, default: "" },
|
|
|
|
|
|
guideManualUrl: { type: String, default: "" },
|
|
|
|
|
|
// AI 辅助指导手册(内部字段沿用 aiManual 命名)
|
|
|
|
|
|
aiManualName: { type: String, default: "" },
|
|
|
|
|
|
aiManualUrl: { type: String, default: "" }
|
|
|
|
|
|
})
|
|
|
|
|
|
const previewVisible = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
function resolveMaterial(name, url) {
|
|
|
|
|
|
const n = String(name || "").trim()
|
|
|
|
|
|
const u = String(url || "").trim()
|
|
|
|
|
|
if (!n || !u || (n === "案例资料.rar" && u === "/file/example.rar")) return null
|
|
|
|
|
|
return { name: n, url: normalizeMaterialUrl(u) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 案例资料优先读取新增的 caseMaterialName/Url,缺省时回退到旧 materialName/Url 字段
|
|
|
|
|
|
const caseMaterial = computed(() => resolveMaterial(props.caseMaterialName || props.materialName, props.caseMaterialUrl || props.materialUrl))
|
|
|
|
|
|
const trainingData = computed(() => resolveMaterial(props.trainingDataName, props.trainingDataUrl))
|
|
|
|
|
|
const guideManual = computed(() => resolveMaterial(props.guideManualName, props.guideManualUrl))
|
|
|
|
|
|
const aiManual = computed(() => resolveMaterial(props.aiManualName, props.aiManualUrl))
|
|
|
|
|
|
|
|
|
|
|
|
const extension = computed(() => String(caseMaterial.value?.name || caseMaterial.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")
|
|
|
|
|
|
|
|
|
|
|
|
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 downloadMaterial(material, label) {
|
|
|
|
|
|
if (!material?.url) {
|
|
|
|
|
|
ElMessage.warning(`暂无${label}`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(material.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.name
|
|
|
|
|
|
document.body.appendChild(link)
|
|
|
|
|
|
link.click()
|
|
|
|
|
|
document.body.removeChild(link)
|
|
|
|
|
|
URL.revokeObjectURL(objectUrl)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`${label} download failed`, error)
|
|
|
|
|
|
ElMessage.error(`${label}文件不存在或暂不可下载,请联系管理员重新上传。`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.material-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 6px
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.material-button {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
min-height: 34px;
|
|
|
|
|
|
padding: 0 14px;
|
|
|
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
color: #2563eb;
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.material-button:hover {
|
|
|
|
|
|
color: #2563eb;
|
|
|
|
|
|
border-color: #2563eb;
|
|
|
|
|
|
background: #eff6ff;
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.image-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
max-height: 68vh;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
background: #0b1720
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.image-preview img {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
object-fit: contain
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pdf-preview {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 68vh;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: #fff
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|