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

328 lines
12 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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; }
.training-task-toolbar :deep(.material-actions) { margin-right:auto; }
.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>