|
|
|
|
<template>
|
|
|
|
|
<div class="training-legacy-actions training-actions">
|
|
|
|
|
<el-button class="training-action training-action--save" :loading="saving" @click="saveProgress">保存当前进度</el-button>
|
|
|
|
|
<el-button class="training-action training-action--submit" :loading="saving" @click="submitAnswer">提交任务</el-button>
|
|
|
|
|
<el-button class="training-action training-action--reset" :loading="saving" @click="resetAnswer">重新实训</el-button>
|
|
|
|
|
<el-button class="training-action training-action--export" @click="exportOutcome">导出实训成果</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
taskKey: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: "",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const { proxy } = getCurrentInstance();
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
const resolvedTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
|
|
|
|
|
const savedPayload = ref(null);
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
loadSavedAnswer();
|
|
|
|
|
document.addEventListener("click", restoreActiveSnapshot, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
document.removeEventListener("click", restoreActiveSnapshot, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => resolvedTaskKey.value,
|
|
|
|
|
() => loadSavedAnswer()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
async function loadSavedAnswer() {
|
|
|
|
|
if (!resolvedTaskKey.value) return;
|
|
|
|
|
try {
|
|
|
|
|
const res = await getStudentTrainingAnswer(resolvedTaskKey.value);
|
|
|
|
|
const saved = parseSavedPayload(res?.data?.step1Answer);
|
|
|
|
|
savedPayload.value = saved;
|
|
|
|
|
await restoreSnapshot();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Old pages remain usable when no saved answer exists.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveProgress() {
|
|
|
|
|
await persist("SAVE", "保存成功");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitAnswer() {
|
|
|
|
|
await persist("SUBMIT", "提交成功");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resetAnswer() {
|
|
|
|
|
if (!resolvedTaskKey.value || saving.value) return;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await deleteStudentTrainingAnswer(resolvedTaskKey.value);
|
|
|
|
|
clearFields();
|
|
|
|
|
proxy?.$modal?.msgSuccess?.("已清空");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
|
|
|
|
|
saveAction: "RESET",
|
|
|
|
|
currentStep: 1,
|
|
|
|
|
});
|
|
|
|
|
clearFields();
|
|
|
|
|
proxy?.$modal?.msgSuccess?.("已清空");
|
|
|
|
|
} finally {
|
|
|
|
|
saving.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function persist(saveAction, message, showMessage = true) {
|
|
|
|
|
if (!resolvedTaskKey.value || saving.value) return;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const snapshotKey = getSnapshotKey();
|
|
|
|
|
const existingSnapshots = normalizeSnapshots(savedPayload.value);
|
|
|
|
|
const snapshots = {
|
|
|
|
|
...existingSnapshots,
|
|
|
|
|
[snapshotKey]: {
|
|
|
|
|
fields: collectFields(),
|
|
|
|
|
tables: collectTables(),
|
|
|
|
|
savedAt: new Date().toISOString(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const payload = {
|
|
|
|
|
type: "legacy-training-snapshot-v2",
|
|
|
|
|
route: route.path,
|
|
|
|
|
savedAt: new Date().toISOString(),
|
|
|
|
|
snapshots,
|
|
|
|
|
};
|
|
|
|
|
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
|
|
|
|
|
saveAction,
|
|
|
|
|
currentStep: 1,
|
|
|
|
|
step1Answer: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
savedPayload.value = payload;
|
|
|
|
|
if (showMessage) {
|
|
|
|
|
proxy?.$modal?.msgSuccess?.(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
saving.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getScope() {
|
|
|
|
|
return document.querySelector(".legacy-training-page") || document.querySelector(".training-step-area") || document.body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectFields() {
|
|
|
|
|
const scope = getScope();
|
|
|
|
|
const controls = Array.from(scope.querySelectorAll("input, textarea, select"))
|
|
|
|
|
.filter((el) => !el.closest(".training-ai-sidebar") && !el.closest(".training-legacy-actions"));
|
|
|
|
|
return controls.map((el) => ({
|
|
|
|
|
selector: buildStableSelector(el),
|
|
|
|
|
type: el.type || el.tagName.toLowerCase(),
|
|
|
|
|
value: getControlValue(el),
|
|
|
|
|
checked: Boolean(el.checked),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectTables() {
|
|
|
|
|
const scope = getScope();
|
|
|
|
|
return Array.from(scope.querySelectorAll("table"))
|
|
|
|
|
.filter((table) => !table.closest(".training-ai-sidebar") && !table.closest(".training-legacy-actions"))
|
|
|
|
|
.map((table, tableIndex) => ({
|
|
|
|
|
tableIndex,
|
|
|
|
|
rows: Array.from(table.querySelectorAll("tr")).map((row) =>
|
|
|
|
|
Array.from(row.querySelectorAll("th, td")).map((cell) => cell.innerText.trim())
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSnapshotKey() {
|
|
|
|
|
const activeTab = getScope().querySelector(".top_item .active");
|
|
|
|
|
return activeTab?.innerText?.trim() || route.path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeSnapshots(payload) {
|
|
|
|
|
if (!payload) return {};
|
|
|
|
|
if (payload.snapshots && typeof payload.snapshots === "object") return payload.snapshots;
|
|
|
|
|
if (Array.isArray(payload.fields)) {
|
|
|
|
|
return {
|
|
|
|
|
[payload.route || route.path]: {
|
|
|
|
|
fields: payload.fields,
|
|
|
|
|
tables: [],
|
|
|
|
|
savedAt: payload.savedAt || "",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function restoreSnapshot() {
|
|
|
|
|
const snapshot = normalizeSnapshots(savedPayload.value)[getSnapshotKey()];
|
|
|
|
|
if (!snapshot?.fields?.length) return;
|
|
|
|
|
await nextTick();
|
|
|
|
|
applyFields(snapshot.fields);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreActiveSnapshot(event) {
|
|
|
|
|
if (!event.target?.closest?.(".top_item button")) return;
|
|
|
|
|
persist("SAVE", "", false)
|
|
|
|
|
.catch(() => proxy?.$modal?.msgError?.("切换前的实训数据保存失败"))
|
|
|
|
|
.finally(() => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
restoreSnapshot();
|
|
|
|
|
}, 0);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyFields(fields) {
|
|
|
|
|
fields.forEach((field) => {
|
|
|
|
|
const el = findControl(field.selector);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (el.type === "checkbox" || el.type === "radio") {
|
|
|
|
|
el.checked = Boolean(field.checked);
|
|
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
el.value = field.value ?? "";
|
|
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearFields() {
|
|
|
|
|
collectFields().forEach((field) => {
|
|
|
|
|
const el = findControl(field.selector);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (el.type === "checkbox" || el.type === "radio") {
|
|
|
|
|
el.checked = false;
|
|
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
el.value = "";
|
|
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildStableSelector(el) {
|
|
|
|
|
const scope = getScope();
|
|
|
|
|
const controls = Array.from(scope.querySelectorAll("input, textarea, select"))
|
|
|
|
|
.filter((item) => !item.closest(".training-ai-sidebar") && !item.closest(".training-legacy-actions"));
|
|
|
|
|
return `${el.tagName.toLowerCase()}:${controls.indexOf(el)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findControl(selector) {
|
|
|
|
|
const [tag, indexText] = String(selector || "").split(":");
|
|
|
|
|
const index = Number(indexText);
|
|
|
|
|
if (!tag || Number.isNaN(index)) return null;
|
|
|
|
|
const scope = getScope();
|
|
|
|
|
const controls = Array.from(scope.querySelectorAll("input, textarea, select"))
|
|
|
|
|
.filter((el) => !el.closest(".training-ai-sidebar") && !el.closest(".training-legacy-actions"));
|
|
|
|
|
return controls[index] && controls[index].tagName.toLowerCase() === tag ? controls[index] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getControlValue(el) {
|
|
|
|
|
if (el.type === "checkbox" || el.type === "radio") {
|
|
|
|
|
return el.value;
|
|
|
|
|
}
|
|
|
|
|
return el.value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSavedPayload(value) {
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exportOutcome() {
|
|
|
|
|
const blob = new Blob([JSON.stringify(collectFields(), 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>
|