feat: add editable dynamic training steps

dev-QQq
chenyuan 4 weeks ago
parent 9299eb644b
commit f2d76efee9

@ -14,14 +14,16 @@
<component v-if="!route.meta.link" :is="Component" :key="route.path" /> <component v-if="!route.meta.link" :is="Component" :key="route.path" />
</keep-alive> </keep-alive>
</router-view> </router-view>
<iframe-toggle /> <DynamicTrainingStepNavigator v-if="Number(role) === 4 && route.meta?.pageKey" :task-key="route.meta.pageKey" />
<iframe-toggle />
</section> </section>
</template> </template>
<script setup> <script setup>
import * as indexApi from "@/api/index"; import * as indexApi from "@/api/index";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
import { dynamicRoutes } from "@/router"; import { dynamicRoutes } from "@/router";
import iframeToggle from "./IframeToggle/index"; import iframeToggle from "./IframeToggle/index";
import DynamicTrainingStepNavigator from "@/views/components/DynamicTrainingStepNavigator.vue";
import useTagsViewStore from "@/store/modules/tagsView"; import useTagsViewStore from "@/store/modules/tagsView";
import { getUserInfo } from "@/utils/auth"; import { getUserInfo } from "@/utils/auth";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";

@ -87,8 +87,22 @@ export const constantRoutes = [
}, },
]; ];
// 动态路由,基于用户权限动态去加载 // 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [ export const dynamicRoutes = [
{ {
path: "/training-step",
component: Layout,
roles: ["student"],
hidden: true,
children: [
{
path: ":taskKey/:stepId",
component: () => import("@/views/training/CustomTrainingStepPage.vue"),
name: "CustomTrainingStep",
meta: { title: "自定义实训步骤", affix: true },
},
],
},
{
path: "/ai", path: "/ai",
component: Layout, component: Layout,
redirect: "/ai/assistant", redirect: "/ai/assistant",

@ -0,0 +1,100 @@
<template>
<aside v-if="customSteps.length" class="dynamic-step-navigator">
<span class="navigator-label">补充步骤</span>
<el-button
v-for="step in customSteps"
:key="step.id"
size="small"
plain
@click="openStep(step)"
>
{{ step.name }}
</el-button>
</aside>
</template>
<script setup>
import { getTrainingTaskByKey } from "@/api/trainingTask";
const props = defineProps({
taskKey: {
type: String,
default: "",
},
});
const router = useRouter();
const customSteps = ref([]);
watch(
() => props.taskKey,
async (taskKey) => {
customSteps.value = [];
if (!taskKey) return;
try {
const res = await getTrainingTaskByKey(taskKey);
customSteps.value = parseSteps(res?.data?.steps).filter((step) => step.kind === "custom");
} catch (error) {
// The original task page remains available when task configuration is unavailable.
}
},
{ immediate: true }
);
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",
};
})
.filter((step) => step.name);
}
function openStep(step) {
router.push({ name: "CustomTrainingStep", params: { taskKey: props.taskKey, stepId: step.id } });
}
</script>
<style lang="scss" scoped>
.dynamic-step-navigator {
position: fixed;
right: 28px;
bottom: 24px;
z-index: 20;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: min(560px, calc(100vw - 48px));
padding: 10px 12px;
border: 1px solid rgba(85, 202, 255, 0.42);
border-radius: 12px;
background: rgba(3, 26, 42, 0.94);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
}
.navigator-label {
align-self: center;
color: #8cdfff;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 768px) {
.dynamic-step-navigator { right: 12px; bottom: 12px; }
}
</style>

@ -168,10 +168,14 @@
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
<div class="step-row"> <div class="step-row">
<template v-for="(step, index) in taskForm.steps" :key="index"> <template v-for="(step, index) in taskForm.steps" :key="step.id">
<el-input v-model="taskForm.steps[index]" placeholder="步骤名称" /> <div class="step-item">
<el-input v-model="step.name" placeholder="步骤名称" />
<el-button v-if="!isReadonly" text type="danger" @click="removeStep(index)"></el-button>
</div>
<span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span> <span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span>
</template> </template>
<el-button v-if="!isReadonly" plain class="add-step-button" @click="addStep"></el-button>
</div> </div>
</el-form-item> </el-form-item>
</section> </section>
@ -293,7 +297,7 @@ function createEmptyTask() {
requirements: "", requirements: "",
materialName: "", materialName: "",
materialUrl: "", materialUrl: "",
steps: ["", "", "", ""], steps: createBuiltinSteps(),
sort: 0, sort: 0,
enabled: true, enabled: true,
}; };
@ -311,7 +315,7 @@ function normalizeTask(task) {
requirements: task.requirements || "", requirements: task.requirements || "",
materialName: material.name, materialName: material.name,
materialUrl: material.url, materialUrl: material.url,
steps: normalizeSteps(parseArray(task.steps, ["", "", "", ""])), steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())),
sort: task.sort || 0, sort: task.sort || 0,
enabled: task.enabled !== false, enabled: task.enabled !== false,
}; };
@ -351,11 +355,28 @@ function parseArray(value, fallback) {
} }
function normalizeSteps(steps) { function normalizeSteps(steps) {
const result = steps.filter((item) => String(item || "").trim()).slice(0, 4); const source = Array.isArray(steps) ? steps : [];
while (result.length < 4) { const result = source
result.push(""); .map((item, index) => {
} const raw = item && typeof item === "object" ? item : {};
return result; const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim();
const kind = raw.kind === "custom" ? "custom" : index < 4 ? "builtin" : "custom";
return createStep(name, kind, raw.id);
})
.filter((item) => item.name);
return result.length ? result : [createStep()];
}
function createBuiltinSteps() {
return [1, 2, 3, 4].map((index) => createStep("", "builtin", `builtin-${index}`));
}
function createStep(name = "", kind = "custom", id = "") {
return {
id: id || `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
kind,
};
} }
function openTaskDialog(task, mode) { function openTaskDialog(task, mode) {
@ -369,7 +390,7 @@ function cloneTask(task) {
return { return {
...task, ...task,
objectives: [...(task.objectives?.length ? task.objectives : [""])], objectives: [...(task.objectives?.length ? task.objectives : [""])],
steps: normalizeSteps(task.steps?.length ? task.steps : ["", "", "", ""]), steps: normalizeSteps(task.steps?.length ? task.steps : createBuiltinSteps()),
}; };
} }
@ -382,6 +403,15 @@ function removeObjective(index) {
taskForm.objectives.splice(index, 1); taskForm.objectives.splice(index, 1);
} }
function addStep() {
taskForm.steps.push(createStep());
}
function removeStep(index) {
if (taskForm.steps.length <= 1) return;
taskForm.steps.splice(index, 1);
}
async function handleImportChange(file) { async function handleImportChange(file) {
if (!file?.raw) return; if (!file?.raw) return;
await importTrainingTasks(file.raw); await importTrainingTasks(file.raw);
@ -443,7 +473,7 @@ function buildPayload() {
background: taskForm.background, background: taskForm.background,
objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)), objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)),
requirements: taskForm.requirements, requirements: taskForm.requirements,
steps: JSON.stringify(taskForm.steps.map((item) => item.trim()).filter(Boolean)), steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)),
materialName: taskForm.materialName, materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl, materialUrl: taskForm.materialUrl,
sort: taskForm.sort, sort: taskForm.sort,
@ -1150,13 +1180,25 @@ function buildPayload() {
} }
.step-row { .step-row {
display: grid; display: flex;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; flex-wrap: wrap;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.step-item {
display: flex;
align-items: center;
gap: 4px;
min-width: 230px;
flex: 1 1 260px;
}
.add-step-button {
border-style: dashed;
}
.step-arrow { .step-arrow {
color: #7ddaff; color: #7ddaff;
font-weight: 800; font-weight: 800;
@ -1213,7 +1255,7 @@ function buildPayload() {
} }
.step-row { .step-row {
grid-template-columns: 1fr; align-items: stretch;
} }
.step-arrow { .step-arrow {

@ -104,7 +104,7 @@
<div class="drawer-heading"> <div class="drawer-heading">
<span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span> <span class="drawer-kicker">{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}</span>
<h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3> <h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3>
<p>任务数量与步骤数量由系统固定老师可维护任务显示名称实训说明目标要求和步骤名称</p> <p>老师可维护任务显示名称实训说明目标要求和步骤并可新增或删除步骤</p>
</div> </div>
<div class="drawer-header-actions"> <div class="drawer-header-actions">
<button type="button" class="drawer-close-button" aria-label="" @click="dialogVisible = false">x</button> <button type="button" class="drawer-close-button" aria-label="" @click="dialogVisible = false">x</button>
@ -190,10 +190,14 @@
<el-form-item label="步骤名称:"> <el-form-item label="步骤名称:">
<div class="step-row"> <div class="step-row">
<template v-for="(step, index) in taskForm.steps" :key="index"> <template v-for="(step, index) in taskForm.steps" :key="step.id">
<el-input v-model="taskForm.steps[index]" placeholder="步骤名称" /> <div class="step-item">
<el-input v-model="step.name" placeholder="步骤名称" />
<el-button v-if="!isReadonly" text type="danger" @click="removeStep(index)"></el-button>
</div>
<span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span> <span v-if="index < taskForm.steps.length - 1" class="step-arrow">&gt;&gt;&gt;</span>
</template> </template>
<el-button v-if="!isReadonly" plain class="add-step-button" @click="addStep"></el-button>
</div> </div>
</el-form-item> </el-form-item>
</section> </section>
@ -388,7 +392,7 @@ function createEmptyTask() {
requirements: "", requirements: "",
materialName: "", materialName: "",
materialUrl: "", materialUrl: "",
steps: ["", "", "", ""], steps: createBuiltinSteps(),
sort: 0, sort: 0,
enabled: true, enabled: true,
}; };
@ -406,7 +410,7 @@ function normalizeTask(task) {
requirements: task.requirements || "", requirements: task.requirements || "",
materialName: material.name, materialName: material.name,
materialUrl: material.url, materialUrl: material.url,
steps: normalizeSteps(parseArray(task.steps, ["", "", "", ""])), steps: normalizeSteps(parseArray(task.steps, createBuiltinSteps())),
sort: task.sort || 0, sort: task.sort || 0,
enabled: task.enabled !== false, enabled: task.enabled !== false,
}; };
@ -446,11 +450,28 @@ function parseArray(value, fallback) {
} }
function normalizeSteps(steps) { function normalizeSteps(steps) {
const result = steps.filter((item) => String(item || "").trim()).slice(0, 4); const source = Array.isArray(steps) ? steps : [];
while (result.length < 4) { const result = source
result.push(""); .map((item, index) => {
} const raw = item && typeof item === "object" ? item : {};
return result; const name = typeof item === "object" ? String(raw.name || "").trim() : String(item || "").trim();
const kind = raw.kind === "custom" ? "custom" : index < 4 ? "builtin" : "custom";
return createStep(name, kind, raw.id);
})
.filter((item) => item.name);
return result.length ? result : [createStep()];
}
function createBuiltinSteps() {
return [1, 2, 3, 4].map((index) => createStep("", "builtin", `builtin-${index}`));
}
function createStep(name = "", kind = "custom", id = "") {
return {
id: id || `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
kind,
};
} }
function openTaskDialog(task, mode) { function openTaskDialog(task, mode) {
@ -463,7 +484,7 @@ function cloneTask(task) {
return { return {
...task, ...task,
objectives: [...(task.objectives?.length ? task.objectives : [""])], objectives: [...(task.objectives?.length ? task.objectives : [""])],
steps: normalizeSteps(task.steps?.length ? task.steps : ["", "", "", ""]), steps: normalizeSteps(task.steps?.length ? task.steps : createBuiltinSteps()),
}; };
} }
@ -476,6 +497,15 @@ function removeObjective(index) {
taskForm.objectives.splice(index, 1); taskForm.objectives.splice(index, 1);
} }
function addStep() {
taskForm.steps.push(createStep());
}
function removeStep(index) {
if (taskForm.steps.length <= 1) return;
taskForm.steps.splice(index, 1);
}
async function handleMaterialChange(file) { async function handleMaterialChange(file) {
const rawFile = file?.raw; const rawFile = file?.raw;
if (!rawFile) return; if (!rawFile) return;
@ -579,7 +609,7 @@ function buildPayload() {
background: taskForm.background, background: taskForm.background,
objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)), objectives: JSON.stringify(taskForm.objectives.map((item) => item.trim()).filter(Boolean)),
requirements: taskForm.requirements, requirements: taskForm.requirements,
steps: JSON.stringify(taskForm.steps.map((item) => item.trim()).filter(Boolean)), steps: JSON.stringify(taskForm.steps.map((item) => ({ ...item, name: item.name.trim() })).filter((item) => item.name)),
materialName: taskForm.materialName, materialName: taskForm.materialName,
materialUrl: taskForm.materialUrl, materialUrl: taskForm.materialUrl,
sort: taskForm.sort, sort: taskForm.sort,
@ -1283,13 +1313,25 @@ function buildPayload() {
} }
.step-row { .step-row {
display: grid; display: flex;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; flex-wrap: wrap;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.step-item {
display: flex;
align-items: center;
gap: 4px;
min-width: 230px;
flex: 1 1 260px;
}
.add-step-button {
border-style: dashed;
}
.step-arrow { .step-arrow {
color: #7ddaff; color: #7ddaff;
font-weight: 800; font-weight: 800;
@ -1342,7 +1384,7 @@ function buildPayload() {
} }
.step-row { .step-row {
grid-template-columns: 1fr; align-items: stretch;
} }
.step-arrow { .step-arrow {

@ -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>

@ -276,14 +276,14 @@ function normalizeMaterial(materialName, materialUrl) {
function parseArray(value) { function parseArray(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.filter(Boolean); return value.map((item) => String(item?.name ?? item ?? "").trim()).filter(Boolean);
} }
if (!value) { if (!value) {
return []; return [];
} }
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter(Boolean) : []; return Array.isArray(parsed) ? parsed.map((item) => String(item?.name ?? item ?? "").trim()).filter(Boolean) : [];
} catch (error) { } catch (error) {
return String(value) return String(value)
.split(/[;|]/) .split(/[;|]/)

@ -39,7 +39,7 @@ export function resolveTrainingTaskKey(route, explicitTaskKey = "") {
export function parseTrainingArray(value, fallback = []) { export function parseTrainingArray(value, fallback = []) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const result = value.map((item) => String(item || "").trim()).filter(Boolean); const result = value.map((item) => String(item?.name ?? item ?? "").trim()).filter(Boolean);
return result.length ? result : fallback; return result.length ? result : fallback;
} }
if (!value) { if (!value) {
@ -48,7 +48,7 @@ export function parseTrainingArray(value, fallback = []) {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const result = parsed.map((item) => String(item || "").trim()).filter(Boolean); const result = parsed.map((item) => String(item?.name ?? item ?? "").trim()).filter(Boolean);
return result.length ? result : fallback; return result.length ? result : fallback;
} }
} catch (error) { } catch (error) {

Loading…
Cancel
Save