|
|
|
|
@ -0,0 +1,172 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="custom-training-step-page">
|
|
|
|
|
<main v-if="step" class="step-shell">
|
|
|
|
|
<header class="step-header">
|
|
|
|
|
<p class="eyebrow">自定义实训步骤</p>
|
|
|
|
|
<h1>{{ task?.taskName }} · {{ step.name }}</h1>
|
|
|
|
|
<p>请完成本步骤的说明、分析结论或成果附件;可先保存,确认后单独提交本步骤。</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<TrainingTaskBrief
|
|
|
|
|
:background="task?.background"
|
|
|
|
|
:goals="task?.objectives"
|
|
|
|
|
:requirements="task?.requirements"
|
|
|
|
|
:material-name="task?.materialName"
|
|
|
|
|
:material-url="task?.materialUrl"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<section class="answer-card">
|
|
|
|
|
<h2>{{ step.name }}</h2>
|
|
|
|
|
<el-input
|
|
|
|
|
v-model="form.content"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:autosize="{ minRows: 10, maxRows: 20 }"
|
|
|
|
|
placeholder="填写本步骤的完成过程、分析结果和结论"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div class="attachment-area">
|
|
|
|
|
<input ref="fileInput" class="hidden-input" type="file" @change="uploadFile" />
|
|
|
|
|
<el-button :loading="uploading" @click="fileInput?.click()">上传成果附件</el-button>
|
|
|
|
|
<a v-for="file in form.attachments" :key="file.url" :href="file.url" target="_blank" rel="noopener">
|
|
|
|
|
{{ file.name }}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<footer class="actions">
|
|
|
|
|
<el-button @click="router.back()">返回任务</el-button>
|
|
|
|
|
<el-button :loading="saving" @click="save(false)">保存草稿</el-button>
|
|
|
|
|
<el-button type="primary" :loading="saving" @click="save(true)">提交本步骤</el-button>
|
|
|
|
|
</footer>
|
|
|
|
|
</main>
|
|
|
|
|
<el-empty v-else description="未找到这个自定义实训步骤" />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const { proxy } = getCurrentInstance();
|
|
|
|
|
const task = ref(null);
|
|
|
|
|
const step = ref(null);
|
|
|
|
|
const form = reactive({ content: "", attachments: [] });
|
|
|
|
|
const dynamicAnswers = ref({});
|
|
|
|
|
const fileInput = ref(null);
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
const uploading = ref(false);
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => [route.params.taskKey, route.params.stepId],
|
|
|
|
|
() => load(),
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
task.value = null;
|
|
|
|
|
step.value = null;
|
|
|
|
|
form.content = "";
|
|
|
|
|
form.attachments = [];
|
|
|
|
|
dynamicAnswers.value = {};
|
|
|
|
|
const taskKey = String(route.params.taskKey || "");
|
|
|
|
|
const stepId = String(route.params.stepId || "");
|
|
|
|
|
if (!taskKey || !stepId) return;
|
|
|
|
|
try {
|
|
|
|
|
const [taskRes, answerRes] = await Promise.all([getTrainingTaskByKey(taskKey), getStudentTrainingAnswer(taskKey)]);
|
|
|
|
|
task.value = taskRes?.data || null;
|
|
|
|
|
step.value = parseSteps(task.value?.steps).find((item) => item.id === stepId && item.kind === "custom") || null;
|
|
|
|
|
dynamicAnswers.value = parseAnswers(answerRes?.data?.dynamicStepAnswers);
|
|
|
|
|
const saved = dynamicAnswers.value[stepId] || {};
|
|
|
|
|
form.content = String(saved.content || "");
|
|
|
|
|
form.attachments = Array.isArray(saved.attachments) ? saved.attachments : [];
|
|
|
|
|
} catch (error) {
|
|
|
|
|
proxy?.$modal?.msgError("加载自定义步骤失败");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSteps(value) {
|
|
|
|
|
let source = value;
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
try { source = JSON.parse(value); } catch (error) { source = value.split(/[,,\n]/); }
|
|
|
|
|
}
|
|
|
|
|
if (!Array.isArray(source)) return [];
|
|
|
|
|
return source.map((item, index) => {
|
|
|
|
|
const raw = item && typeof item === "object" ? item : {};
|
|
|
|
|
const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim();
|
|
|
|
|
return {
|
|
|
|
|
id: raw.id || `${index < 4 ? "builtin" : "custom"}-${index + 1}`,
|
|
|
|
|
name,
|
|
|
|
|
kind: raw.kind === "custom" || (!raw.kind && index >= 4) ? "custom" : "builtin",
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseAnswers(value) {
|
|
|
|
|
if (!value) return {};
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(value);
|
|
|
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadFile(event) {
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const data = new FormData();
|
|
|
|
|
data.append("file", file);
|
|
|
|
|
uploading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const res = await uploadStudentTrainingFile(data);
|
|
|
|
|
const url = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || "";
|
|
|
|
|
if (!url) throw new Error("missing upload url");
|
|
|
|
|
form.attachments.push({ name: file.name, url });
|
|
|
|
|
proxy?.$modal?.msgSuccess("上传成功");
|
|
|
|
|
} finally {
|
|
|
|
|
uploading.value = false;
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function save(submitStep) {
|
|
|
|
|
if (!step.value || saving.value) return;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
dynamicAnswers.value[step.value.id] = {
|
|
|
|
|
content: form.content,
|
|
|
|
|
attachments: form.attachments,
|
|
|
|
|
submitted: submitStep,
|
|
|
|
|
submittedAt: submitStep ? new Date().toISOString() : null,
|
|
|
|
|
};
|
|
|
|
|
const stepIndex = parseSteps(task.value?.steps).findIndex((item) => item.id === step.value.id);
|
|
|
|
|
await saveStudentTrainingAnswer(String(route.params.taskKey), {
|
|
|
|
|
saveAction: "SAVE",
|
|
|
|
|
currentStep: Math.max(1, stepIndex + 1),
|
|
|
|
|
dynamicStepAnswers: JSON.stringify(dynamicAnswers.value),
|
|
|
|
|
});
|
|
|
|
|
proxy?.$modal?.msgSuccess(submitStep ? "本步骤已提交" : "草稿已保存");
|
|
|
|
|
} finally {
|
|
|
|
|
saving.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.custom-training-step-page { min-height: 100%; padding: 28px; color: #e9f7ff; }
|
|
|
|
|
.step-shell { max-width: 1080px; margin: 0 auto; }
|
|
|
|
|
.step-header, .answer-card { padding: 22px; border: 1px solid rgba(70, 181, 231, .36); border-radius: 16px; background: rgba(2, 22, 37, .82); }
|
|
|
|
|
.step-header { margin-bottom: 20px; }
|
|
|
|
|
.step-header h1 { margin: 5px 0 10px; font-size: 26px; }
|
|
|
|
|
.step-header p { margin: 0; color: #b9ddf1; line-height: 1.75; }
|
|
|
|
|
.eyebrow { color: #72dcff !important; font-weight: 800; }
|
|
|
|
|
.answer-card h2 { margin-top: 0; font-size: 20px; }
|
|
|
|
|
.attachment-area { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; }
|
|
|
|
|
.attachment-area a { color: #75dcff; }
|
|
|
|
|
.hidden-input { display: none; }
|
|
|
|
|
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
|
|
|
|
@media (max-width: 768px) { .custom-training-step-page { padding: 14px; } .actions { flex-wrap: wrap; } }
|
|
|
|
|
</style>
|