You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/components/TrainingTaskToolbar.vue

270 lines
10 KiB
Vue

<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--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 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 (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 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:36px; height:36px; padding:0 14px; border-color:rgba(99,217,255,.55); border-radius:8px; color:#e9fbff; background:rgba(17,126,178,.36); font-size:13px; font-weight:800; }
.task-toolbar-button:hover { border-color:rgba(116,235,255,.85); color:#e9fbff; background:rgba(18,146,204,.5); }
.task-toolbar-button--submit { border-color:rgba(66,224,187,.7); background:rgba(13,137,111,.34); }
.task-toolbar-button--submit:hover { border-color:rgba(100,245,208,.9); background:rgba(14,164,132,.45); }
@media (max-width:768px) { .training-task-toolbar { justify-content:stretch; } .training-task-toolbar > * { flex:1 1 auto; } }
</style>