|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="training-task-toolbar">
|
|
|
|
|
|
<TrainingMaterialButton
|
|
|
|
|
|
:material-name="resolvedMaterial.name"
|
|
|
|
|
|
:material-url="resolvedMaterial.url"
|
|
|
|
|
|
:training-data-name="resolvedTrainingData.name"
|
|
|
|
|
|
:training-data-url="resolvedTrainingData.url"
|
|
|
|
|
|
:guide-manual-name="resolvedGuideManual.name"
|
|
|
|
|
|
:guide-manual-url="resolvedGuideManual.url"
|
|
|
|
|
|
:ai-manual-name="resolvedAiManual.name"
|
|
|
|
|
|
:ai-manual-url="resolvedAiManual.url"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<el-button class="task-toolbar-button task-toolbar-button--save" :loading="savingProgress" @click="triggerExistingAction('save')">
|
|
|
|
|
|
<el-icon><CircleCheck /></el-icon>保存当前进度
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button class="task-toolbar-button task-toolbar-button--submit" @click="triggerExistingAction('submit')">
|
|
|
|
|
|
<el-icon><CircleCheck /></el-icon>提交任务
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button class="task-toolbar-button" @click="triggerExistingAction('reset')">
|
|
|
|
|
|
<el-icon><RefreshLeft /></el-icon>重新实训
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button class="task-toolbar-button" :loading="exporting" @click="exportCurrentTrainingOutcome">
|
|
|
|
|
|
<el-icon><Download /></el-icon>导出实训成果
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
|
|
|
|
import { CircleCheck, Download, RefreshLeft } from "@element-plus/icons-vue";
|
|
|
|
|
|
import { ElMessage } from "element-plus";
|
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
|
import { deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
|
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
|
|
|
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
|
|
|
import { collectTrainingOutcomeDetails, exportTrainingOutcomePdf } from "@/utils/trainingOutcomePdf";
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
materialName: { type: String, default: "" },
|
|
|
|
|
|
materialUrl: { type: String, default: "" },
|
|
|
|
|
|
trainingDataName: { type: String, default: "" },
|
|
|
|
|
|
trainingDataUrl: { type: String, default: "" },
|
|
|
|
|
|
guideManualName: { type: String, default: "" },
|
|
|
|
|
|
guideManualUrl: { type: String, default: "" },
|
|
|
|
|
|
aiManualName: { type: String, default: "" },
|
|
|
|
|
|
aiManualUrl: { type: String, default: "" },
|
|
|
|
|
|
taskKey: { type: String, default: "" },
|
|
|
|
|
|
onSubmit: { type: Function, default: null },
|
|
|
|
|
|
onReset: { type: Function, default: null },
|
|
|
|
|
|
onExport: { type: Function, default: null },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const loadedMaterial = ref({
|
|
|
|
|
|
name: "",
|
|
|
|
|
|
url: "",
|
|
|
|
|
|
trainingDataName: "",
|
|
|
|
|
|
trainingDataUrl: "",
|
|
|
|
|
|
guideManualName: "",
|
|
|
|
|
|
guideManualUrl: "",
|
|
|
|
|
|
aiManualName: "",
|
|
|
|
|
|
aiManualUrl: ""
|
|
|
|
|
|
});
|
|
|
|
|
|
const loadedTask = ref(null);
|
|
|
|
|
|
const exporting = ref(false);
|
|
|
|
|
|
const savingProgress = ref(false);
|
|
|
|
|
|
const resolvedTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
|
|
|
|
|
|
const resolvedMaterial = computed(() => ({
|
|
|
|
|
|
name: String(props.materialName || loadedMaterial.value.name || ""),
|
|
|
|
|
|
url: String(props.materialUrl || loadedMaterial.value.url || ""),
|
|
|
|
|
|
}));
|
|
|
|
|
|
const resolvedTrainingData = computed(() => ({
|
|
|
|
|
|
name: String(props.trainingDataName || loadedMaterial.value.trainingDataName || ""),
|
|
|
|
|
|
url: String(props.trainingDataUrl || loadedMaterial.value.trainingDataUrl || ""),
|
|
|
|
|
|
}));
|
|
|
|
|
|
const resolvedGuideManual = computed(() => ({
|
|
|
|
|
|
name: String(props.guideManualName || loadedMaterial.value.guideManualName || ""),
|
|
|
|
|
|
url: String(props.guideManualUrl || loadedMaterial.value.guideManualUrl || ""),
|
|
|
|
|
|
}));
|
|
|
|
|
|
const resolvedAiManual = computed(() => ({
|
|
|
|
|
|
name: String(props.aiManualName || loadedMaterial.value.aiManualName || ""),
|
|
|
|
|
|
url: String(props.aiManualUrl || loadedMaterial.value.aiManualUrl || ""),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(loadTaskConfig);
|
|
|
|
|
|
watch(resolvedTaskKey, loadTaskConfig);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadTaskConfig() {
|
|
|
|
|
|
if (!resolvedTaskKey.value) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getTrainingTaskByKey(resolvedTaskKey.value);
|
|
|
|
|
|
const task = response?.data || response || {};
|
|
|
|
|
|
loadedTask.value = task;
|
|
|
|
|
|
loadedMaterial.value = {
|
|
|
|
|
|
name: String(props.materialName || task.materialName || ""),
|
|
|
|
|
|
url: String(props.materialUrl || task.materialUrl || ""),
|
|
|
|
|
|
trainingDataName: String(task.trainingDataName || ""),
|
|
|
|
|
|
trainingDataUrl: String(task.trainingDataUrl || ""),
|
|
|
|
|
|
guideManualName: String(task.guideManualName || ""),
|
|
|
|
|
|
guideManualUrl: String(task.guideManualUrl || ""),
|
|
|
|
|
|
aiManualName: String(task.aiManualName || ""),
|
|
|
|
|
|
aiManualUrl: String(task.aiManualUrl || "")
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// The material button keeps its unavailable state when a task has no configured case file.
|
|
|
|
|
|
loadedTask.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function exportCurrentTrainingOutcome() {
|
|
|
|
|
|
if (exporting.value) return;
|
|
|
|
|
|
exporting.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
let task = loadedTask.value;
|
|
|
|
|
|
if (!task?.taskName) {
|
|
|
|
|
|
const response = await getTrainingTaskByKey(resolvedTaskKey.value);
|
|
|
|
|
|
task = response?.data || response || null;
|
|
|
|
|
|
loadedTask.value = task;
|
|
|
|
|
|
}
|
|
|
|
|
|
let studentAnswer = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const answerResponse = await getStudentTrainingAnswer(resolvedTaskKey.value);
|
|
|
|
|
|
studentAnswer = answerResponse?.data || null;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 当前页面的填写值仍可导出;已保存记录不可用时不阻塞学生下载任务说明。
|
|
|
|
|
|
}
|
|
|
|
|
|
await exportTrainingOutcomePdf({
|
|
|
|
|
|
...task,
|
|
|
|
|
|
stepDetails: collectTrainingOutcomeDetails(task, studentAnswer),
|
|
|
|
|
|
});
|
|
|
|
|
|
ElMessage.success("PDF 实训成果已开始下载");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("导出实训成果 PDF 失败", error);
|
|
|
|
|
|
ElMessage.error(error?.message || "PDF 导出失败,请稍后重试。");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
exporting.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function triggerExistingAction(action) {
|
|
|
|
|
|
const directActions = {
|
|
|
|
|
|
submit: props.onSubmit,
|
|
|
|
|
|
reset: props.onReset,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (action === "save") {
|
|
|
|
|
|
saveProgress();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (directActions[action]) {
|
|
|
|
|
|
directActions[action]();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const labelPriority = {
|
|
|
|
|
|
submit: [[/提交任务/], [/完成实训/, /保存并完成/, /校验并完成/]],
|
|
|
|
|
|
reset: [[/重新实训/]],
|
|
|
|
|
|
export: [[/导出实训成果/]],
|
|
|
|
|
|
};
|
|
|
|
|
|
const buttons = getCurrentTaskActionButtons();
|
|
|
|
|
|
const target = labelPriority[action]
|
|
|
|
|
|
.map((matchers) => buttons.find((button) => matchers.some((matcher) => matcher.test(button.innerText || ""))))
|
|
|
|
|
|
.find(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
target.click();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fallbackActions = {
|
|
|
|
|
|
submit: saveLegacyTraining,
|
|
|
|
|
|
reset: resetLegacyTraining,
|
|
|
|
|
|
export: exportLegacyTraining,
|
|
|
|
|
|
};
|
|
|
|
|
|
fallbackActions[action]?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentTaskActionButtons() {
|
|
|
|
|
|
const shell = document.querySelector(".student-training-shell") || document;
|
|
|
|
|
|
const actionAreas = Array.from(shell.querySelectorAll(
|
|
|
|
|
|
".step-actions, .step-navigation, .footer-actions, .training-actions, .task-actions, .taskfooter, .training-step-area"
|
|
|
|
|
|
));
|
|
|
|
|
|
const candidates = actionAreas.length
|
|
|
|
|
|
? actionAreas.flatMap((area) => Array.from(area.querySelectorAll("button")))
|
|
|
|
|
|
: Array.from(shell.querySelectorAll("button"));
|
|
|
|
|
|
return Array.from(new Set(candidates))
|
|
|
|
|
|
.filter((button) => !button.closest(".training-task-toolbar") && isVisible(button) && !button.disabled);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isVisible(element) {
|
|
|
|
|
|
const style = window.getComputedStyle(element);
|
|
|
|
|
|
return style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLegacyTrainingScope() {
|
|
|
|
|
|
return document.querySelector(".student-training-shell") || document.querySelector(".training-step-area") || document.body;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectLegacyFields() {
|
|
|
|
|
|
const scope = getLegacyTrainingScope();
|
|
|
|
|
|
return Array.from(scope.querySelectorAll("input, textarea, select"))
|
|
|
|
|
|
.filter((element) => !element.closest(".training-ai-sidebar") && !element.closest(".training-task-toolbar"))
|
|
|
|
|
|
.map((element, index) => ({
|
|
|
|
|
|
tag: element.tagName.toLowerCase(),
|
|
|
|
|
|
index,
|
|
|
|
|
|
type: element.type || element.tagName.toLowerCase(),
|
|
|
|
|
|
value: element.value ?? "",
|
|
|
|
|
|
checked: Boolean(element.checked),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveProgress() {
|
|
|
|
|
|
if (savingProgress.value) return;
|
|
|
|
|
|
// 优先触发页面自身的保存逻辑(保留各页面的校验与状态文案)
|
|
|
|
|
|
const saveLabelPriority = [[/保存当前进度/], [/保存进度/], [/保存(?!成功|失败|中)/]];
|
|
|
|
|
|
const buttons = getCurrentTaskActionButtons();
|
|
|
|
|
|
const target = saveLabelPriority
|
|
|
|
|
|
.map((matchers) => buttons.find((button) => matchers.some((matcher) => matcher.test(button.innerText || ""))))
|
|
|
|
|
|
.find(Boolean);
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
savingProgress.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
target.click();
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
|
|
|
|
ElMessage.success("进度已保存");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProgress.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 页面没有保存按钮时保存表单快照(SAVE 不等于提交)
|
|
|
|
|
|
if (!resolvedTaskKey.value) {
|
|
|
|
|
|
ElMessage.warning("当前任务暂未配置,无法保存。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
savingProgress.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
|
|
|
|
|
|
saveAction: "SAVE",
|
|
|
|
|
|
currentStep: 1,
|
|
|
|
|
|
step1Answer: JSON.stringify({
|
|
|
|
|
|
type: "legacy-training-snapshot-v3",
|
|
|
|
|
|
route: route.path,
|
|
|
|
|
|
savedAt: new Date().toISOString(),
|
|
|
|
|
|
fields: collectLegacyFields(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
ElMessage.success("进度已保存");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error("保存失败,请稍后重试。");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProgress.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveLegacyTraining() {
|
|
|
|
|
|
if (!resolvedTaskKey.value) {
|
|
|
|
|
|
ElMessage.warning("当前任务暂未配置,无法提交。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
|
|
|
|
|
|
saveAction: "SUBMIT",
|
|
|
|
|
|
currentStep: 1,
|
|
|
|
|
|
step1Answer: JSON.stringify({
|
|
|
|
|
|
type: "legacy-training-snapshot-v3",
|
|
|
|
|
|
route: route.path,
|
|
|
|
|
|
savedAt: new Date().toISOString(),
|
|
|
|
|
|
fields: collectLegacyFields(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
ElMessage.success("提交任务成功");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error("提交任务失败,请稍后重试。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resetLegacyTraining() {
|
|
|
|
|
|
if (!resolvedTaskKey.value) {
|
|
|
|
|
|
ElMessage.warning("当前任务暂未配置,无法重新实训。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteStudentTrainingAnswer(resolvedTaskKey.value);
|
|
|
|
|
|
const scope = getLegacyTrainingScope();
|
|
|
|
|
|
Array.from(scope.querySelectorAll("input, textarea, select")).forEach((element) => {
|
|
|
|
|
|
if (element.closest(".training-ai-sidebar") || element.closest(".training-task-toolbar")) return;
|
|
|
|
|
|
if (element.type === "checkbox" || element.type === "radio") {
|
|
|
|
|
|
element.checked = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
element.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
|
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
ElMessage.success("已清空全部实训步骤,可以重新开始。");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error("重新实训失败,请稍后重试。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exportLegacyTraining() {
|
|
|
|
|
|
const blob = new Blob([JSON.stringify(collectLegacyFields(), null, 2)], { type: "application/json;charset=utf-8" });
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = URL.createObjectURL(blob);
|
|
|
|
|
|
link.download = `${route.meta?.title || "实训"}-实训成果.json`;
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
URL.revokeObjectURL(link.href);
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.training-task-toolbar { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:8px; }
|
|
|
|
|
|
.task-toolbar-button { min-height:34px; height:34px; padding:0 14px; border-color:rgba(37,99,235,.35); border-radius:8px; color:#2563eb; background:#fff; font-size:13px; font-weight:600; }
|
|
|
|
|
|
.task-toolbar-button:hover { border-color:#2563eb; color:#1d4ed8; background:#eff6ff; }
|
|
|
|
|
|
.task-toolbar-button--save { border-color:rgba(43,164,113,.45); color:#2ba471; }
|
|
|
|
|
|
.task-toolbar-button--save:hover { border-color:#2ba471; color:#1f8f62; background:#f0fbf6; }
|
|
|
|
|
|
.task-toolbar-button--submit { border-color:#2563eb; color:#fff; background:#2563eb; }
|
|
|
|
|
|
.task-toolbar-button--submit:hover { border-color:#1d4ed8; color:#fff; background:#1d4ed8; }
|
|
|
|
|
|
@media (max-width:768px) { .training-task-toolbar { justify-content:stretch; } .training-task-toolbar > * { flex:1 1 auto; } }
|
|
|
|
|
|
</style>
|