fix(training): 修复知识自测答题回显与评阅预览步骤切换

知识自测回显:
- 新产品调查与分析 watch 漏了 stepOneChoiceAnswers,学生只答自测时本地草稿不落盘,刷新即丢
- applyStep1Answer 只在表格分类有值时返回 true,纯答题场景后端回显后 clearDraft 不生效
- 兼容 toolbox DOM 快照 legacy-training-snapshot-v3(教师端评阅预览返回的就是这类数据),
  按「选项以 A 开头分组 + 遇非字母选项停止」还原逐题作答
- 第 4 步 AI 应用价值分析在结构化数据为空时,用快照尾部 textarea 兜底回填(route 限定页面)
- 行业需求分析 / 竞争环境分析同步加上快照兜底

提交链路:
- new-product-survey 工具栏未传 on-submit/on-reset,提交走 DOM 兜底导致第 4 步内容从未落库,
  补上 :on-submit="submitTask" :on-reset="resetExample"
- 工具栏兜底快照改写 dynamicStepAnswers,不再覆盖 step1Answer 里的结构化答题

评阅预览:
- 步骤导航判定仅靠文案太窄(行业/竞争页胶囊文案是「01 PEST宏观环境分析 当前步骤 →」),
  改为容器白名单放行,文案正则补充「步骤 N / 点击切换 / 当前步骤」
- 预览只读拦截与加载失败时透出真实错误信息,不再静默失败
dev-QQq
qinzhenpen 7 hours ago
parent c3a33335a0
commit 332d33bd32

@ -23,6 +23,81 @@ export function createStepOneChoiceQuestion(index = 0) {
};
}
/**
* 兼容「legacy-training-snapshot-v3」整页 DOM 快照:
* 工具栏的提交/保存兜底会把整页 input/textarea 的选中状态拍成快照塞进 step1Answer,
* 这类数据里没有结构化 choiceAnswers,只能按 DOM 顺序把单选组还原成逐题作答。
* 分组规则:选项 key 恒为 A~F 单字母,且每组以 "A" 开头;遇到非单字母选项(如第二步的"京东")
* 说明自测区已结束,停止收集。
*/
export function parseLegacySnapshotChoiceAnswers(value, questions = []) {
let source = value;
if (typeof value === "string") {
try {
source = JSON.parse(value);
} catch {
return null;
}
}
if (!source || typeof source !== "object" || !Array.isArray(source.fields)) return null;
const normalized = Array.isArray(questions) ? questions : parseStepOneChoiceQuestions(questions);
if (!normalized.length) return null;
const groups = [];
let current = null;
for (const field of source.fields) {
if (!field || field.type !== "radio") continue;
const key = String(field.value ?? "").trim().toUpperCase();
if (!/^[A-F]$/.test(key)) {
// 已开始收集自测题后遇到非字母选项,说明后面是其它步骤的控件
if (current) break;
continue;
}
if (key === "A") {
current = [];
groups.push(current);
}
if (!current) continue;
current.push({ key, checked: Boolean(field.checked) });
}
const answers = {};
groups.slice(0, normalized.length).forEach((group, index) => {
const picked = group.find((option) => option.checked);
if (picked) answers[normalized[index].id] = picked.key;
});
return Object.keys(answers).length ? answers : null;
}
/**
* 兼容 legacy 快照里的尾部 textarea(如新产品调查与分析第 4 步的三层次 AI 应用价值分析):
* 快照没有字段名,只能按 DOM 顺序取最后 N 个 textarea。调用方必须用 route 限定页面,
* 避免把其它页面的控件误当成第 4 步内容回填给教师端。
*/
export function parseLegacySnapshotTailTextareas(value, fields = [], route = "") {
let source = value;
if (typeof value === "string") {
try {
source = JSON.parse(value);
} catch {
return null;
}
}
if (!source || typeof source !== "object" || !Array.isArray(source.fields)) return null;
if (route && String(source.route || "") !== route) return null;
if (!Array.isArray(fields) || !fields.length) return null;
const textareas = source.fields.filter((field) => field && (field.type === "textarea" || field.tag === "textarea"));
// 至少要有第 3 步的 rows 加上第 4 步的字段,才可能是“第 4 步在末尾”的结构
if (textareas.length < fields.length + 3) return null;
const tail = textareas.slice(-fields.length);
const result = {};
let filled = false;
tail.forEach((field, index) => {
const text = String(field?.value ?? "").trim();
if (text) filled = true;
result[fields[index]] = text;
});
return filled ? result : null;
}
export function parseStepOneChoiceQuestions(value) {
let source = value;
if (typeof value === "string") {

@ -10,6 +10,15 @@
:ai-manual-name="resolvedAiManual.name"
:ai-manual-url="resolvedAiManual.url"
/>
<!-- 成绩展示:无成绩不显示;有老师评分优先显示老师评分,否则显示 AI 评分 -->
<template v-if="scoreVisible">
<span v-if="displayScore !== null" class="task-toolbar-score" :title="scoreBadgeTitle">
{{ displayScore }}<small>分</small>
</span>
<el-button class="task-toolbar-button" @click="openScoreDialog">
<el-icon><View /></el-icon>查看成绩
</el-button>
</template>
<!-- 保存当前进度按钮已注销
<el-button class="task-toolbar-button task-toolbar-button--save" :loading="savingProgress" @click="triggerExistingAction('save')">
<el-icon><CircleCheck /></el-icon>保存当前进度
@ -25,11 +34,37 @@
<el-icon><Download /></el-icon>导出实训成果
</el-button>
</div>
<!-- 成绩弹窗:成绩 / 评语 / 参考答案,有则展示无则隐藏 -->
<el-dialog v-model="scoreDialogVisible" title="实训成绩" width="560px" append-to-body class="task-score-dialog">
<div class="task-score-body">
<section v-if="hasTeacherScore || hasAiScore" class="task-score-cards">
<div v-if="hasTeacherScore" class="task-score-card task-score-card--teacher">
<p class="task-score-card__label">老师评分</p>
<p class="task-score-card__value">{{ formatScoreValue(teacherScore) }}<small>分</small></p>
<p v-if="teacherScoredAt" class="task-score-card__time">{{ formatScoreTime(teacherScoredAt) }}</p>
</div>
<div v-if="hasAiScore" class="task-score-card task-score-card--ai">
<p class="task-score-card__label">AI 评分</p>
<p class="task-score-card__value">{{ formatScoreValue(aiScore) }}<small>分</small></p>
</div>
</section>
<section v-if="teacherComment" class="task-score-block">
<p class="task-score-block__label">老师评语</p>
<p class="task-score-block__text">{{ teacherComment }}</p>
</section>
<section v-if="referenceAnswer" class="task-score-block">
<p class="task-score-block__label">参考答案</p>
<p class="task-score-block__text">{{ referenceAnswer }}</p>
</section>
<el-empty v-if="!hasScoreData" description="老师还没有批阅这份实训任务" :image-size="90" />
</div>
</el-dialog>
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { CircleCheck, Download, RefreshLeft } from "@element-plus/icons-vue";
import { CircleCheck, Download, RefreshLeft, View } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
@ -66,6 +101,89 @@ const loadedMaterial = ref({
const loadedTask = ref(null);
const exporting = ref(false);
const savingProgress = ref(false);
// ========== 成绩展示状态 ==========
const scoreAnswer = ref(null);
const scoreDialogVisible = ref(false);
// 不同接口对同一分数的命名不一致,统一兜底取值,避免换了字段名就读不到分数
const teacherScore = computed(() => pickScore(scoreAnswer.value, ["teacherScore", "teacherTotalScore"]));
const aiScore = computed(() => pickScore(scoreAnswer.value, ["aiAssessmentScore", "aiTotalScore", "aiScore", "score", "totalScore"]));
const teacherComment = computed(() => pickText(scoreAnswer.value, ["teacherComment", "teacherComments", "comment"]));
const teacherScoredAt = computed(() => String(scoreAnswer.value?.teacherScoredAt || "").trim());
// 参考答案:约定老师“开放推送”后任务配置里才携带该字段(后端字段暂未提供,预留读取,提供后自动生效)
const referenceAnswer = computed(() => String(loadedTask.value?.referenceAnswer || "").trim());
const hasTeacherScore = computed(() => teacherScore.value !== null);
const hasAiScore = computed(() => aiScore.value !== null);
const hasScoreData = computed(() => hasTeacherScore.value || hasAiScore.value || Boolean(teacherComment.value));
// 工具栏入口:分数、评语、参考答案任一存在才显示
const scoreVisible = computed(() => hasScoreData.value || Boolean(referenceAnswer.value));
const displayScore = computed(() => {
if (hasTeacherScore.value) return teacherScore.value;
if (hasAiScore.value) return aiScore.value;
return null;
});
const scoreBadgeTitle = computed(() => (hasTeacherScore.value ? "老师评分,点击查看成绩详情" : "AI 评分,点击查看成绩详情"));
function toScoreNumber(value) {
if (value === null || value === undefined || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function pickScore(source, keys) {
for (const key of keys) {
const num = toScoreNumber(source?.[key]);
if (num !== null) return num;
}
return null;
}
function pickText(source, keys) {
for (const key of keys) {
const text = String(source?.[key] || "").trim();
if (text) return text;
}
return "";
}
function formatScoreValue(value) {
const num = toScoreNumber(value);
if (num === null) return "--";
return num.toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1");
}
function formatScoreTime(value) {
return String(value).replace("T", " ").slice(0, 16);
}
async function loadScoreState() {
const scoreTaskKey = resolvedTaskKey.value;
if (!scoreTaskKey) {
scoreAnswer.value = null;
console.debug("[成绩] 当前页面未解析到 taskKey,不展示成绩:", route.path);
return;
}
try {
const response = await getStudentTrainingAnswer(scoreTaskKey);
scoreAnswer.value = response?.data || null;
if (!scoreAnswer.value) {
console.debug(`[成绩] taskKey=${scoreTaskKey} 暂无答题记录,不展示成绩`);
} else if (!pickScore(scoreAnswer.value, ["teacherScore", "teacherTotalScore"]) &&
!pickScore(scoreAnswer.value, ["aiAssessmentScore", "aiTotalScore", "aiScore", "score", "totalScore"])) {
console.debug(`[成绩] taskKey=${scoreTaskKey} 有答题记录但无评分数据(未提交 / 未批阅 / 未 AI 评分)`);
}
} catch (error) {
// 没有答题记录或接口异常时按“无成绩”处理,工具栏不显示成绩入口。
scoreAnswer.value = null;
console.debug(`[成绩] taskKey=${scoreTaskKey} 成绩加载失败,不展示成绩`, error?.message || error);
}
}
function openScoreDialog() {
// 打开时重新拉取,保证教师刚评完的分/评语能及时看到。
loadScoreState();
scoreDialogVisible.value = true;
}
const resolvedTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const resolvedMaterial = computed(() => ({
name: String(props.materialName || loadedMaterial.value.name || ""),
@ -107,6 +225,7 @@ async function loadTaskConfig() {
// The material button keeps its unavailable state when a task has no configured case file.
loadedTask.value = null;
}
loadScoreState();
}
async function exportCurrentTrainingOutcome() {
@ -238,10 +357,15 @@ async function saveProgress() {
}
savingProgress.value = true;
try {
/*
* 快照写入 dynamicStepAnswers 而不是 step1Answer:
* step1Answer 是新版页面自身的结构化答案(含知识自测 choiceAnswers),
* 用整页 DOM 快照覆盖会直接冲掉学生已保存的答题,导致学生端与教师端评阅都回显不出来。
*/
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
saveAction: "SAVE",
currentStep: 1,
step1Answer: JSON.stringify({
dynamicStepAnswers: JSON.stringify({
type: "legacy-training-snapshot-v3",
route: route.path,
savedAt: new Date().toISOString(),
@ -265,7 +389,8 @@ async function saveLegacyTraining() {
await saveStudentTrainingAnswer(resolvedTaskKey.value, {
saveAction: "SUBMIT",
currentStep: 1,
step1Answer: JSON.stringify({
/* 同上:快照落到 dynamicStepAnswers,避免覆盖 step1Answer 里的结构化答题 */
dynamicStepAnswers: JSON.stringify({
type: "legacy-training-snapshot-v3",
route: route.path,
savedAt: new Date().toISOString(),
@ -324,4 +449,29 @@ function exportLegacyTraining() {
.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; } }
/* 成绩徽标:琥珀色,区别于蓝色系工具栏按钮 */
.task-toolbar-score {
display:inline-flex; align-items:center; gap:2px; height:34px; padding:0 12px;
border-radius:8px; background:#fff7ed;
border:1px solid rgba(180,83,9,.35); color:#b45309; font-weight:700; font-size:16px; line-height:1;
white-space:nowrap;
}
.task-toolbar-score small { font-size: var(--student-fs-body); font-weight:600; }
/* 成绩弹窗 */
.task-score-body { display:flex; flex-direction:column; gap:16px; }
.task-score-cards { display:flex; gap:12px; }
.task-score-card { flex:1; border-radius:10px; padding:14px 16px; border:1px solid; }
.task-score-card--teacher { background:#eff6ff; border-color:rgba(37,99,235,.35); }
.task-score-card--ai { background:#f0fbf6; border-color:rgba(43,164,113,.35); }
.task-score-card__label { margin:0 0 4px; font-size:12px; color:#64748b; }
.task-score-card__value { margin:0; font-size:28px; font-weight:700; line-height:1.2; }
.task-score-card--teacher .task-score-card__value { color:#1d4ed8; }
.task-score-card--ai .task-score-card__value { color:#1f8f62; }
.task-score-card__value small { font-size:13px; font-weight:500; margin-left:2px; color:#64748b; }
.task-score-card__time { margin:6px 0 0; font-size:12px; color:#94a3b8; }
.task-score-block { border-radius:10px; border:1px solid rgba(100,116,139,.25); background:#f8fafc; padding:12px 16px; }
.task-score-block__label { margin:0 0 6px; font-size:12px; font-weight:600; color:#475569; }
.task-score-block__text { margin:0; font-size:14px; line-height:1.7; color:#1e293b; white-space:pre-wrap; word-break:break-word; }
</style>

@ -271,6 +271,7 @@ import useUserStore from "@/store/modules/user"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import TrainingStepOneChoiceQuestions from "@/views/components/TrainingStepOneChoiceQuestions.vue"
import { parseLegacySnapshotChoiceAnswers, parseStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"
const TASK_KEY = "competitive-environment-analysis"
const { proxy } = getCurrentInstance()
@ -291,7 +292,7 @@ const strategyDefinitions = [
const overallAssessmentOptions = ["优势明显,建议快速进入", "机会大于挑战,建议积极布局", "挑战与机会并存,需谨慎推进", "劣势突出,建议暂缓进入"]
const defaultTask = { title: "竞争环境分析", background: "通过 SWOT 模型归类案例资料中的竞争环境信息,为后续竞品比较与竞争策略判断提供依据。", goals: ["掌握 SWOT 四维度的分类方法", "提炼案例中的竞争环境关键信息", "形成竞争分析的基础材料"], requirement: "请阅读案例资料,将相关信息归类为优势、劣势、机会和威胁。" }
const taskConfig = ref(null); const briefOpen = ref(true); const currentStep = ref(1); const saving = ref(false); const draftReady = ref(false); const stepOnePassed = ref(false); const stepTwoPassed = ref(false); const stepThreePassed = ref(false); const form = ref(createForm()); const supplementForm = ref(createForm()); const strategyForm = ref(createStrategyForm()); const finalForm = ref(createFinalForm()); const stepOneChoiceAnswers = ref({})
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS)); const progressItems = computed(() => swotRows.map((row) => ({ text: `${strategyForm.value.so.recommendation ? "已制定策略" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: strategyForm.value.so.recommendation || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" })))
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS)); /* 知识自测题目(含 id),legacy 快照回显时按题目顺序还原选项 */ const stepOneQuestions = computed(() => parseStepOneChoiceQuestions(taskConfig.value?.stepOneQuestions)); const progressItems = computed(() => swotRows.map((row) => ({ text: `${strategyForm.value.so.recommendation ? "已制定策略" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: strategyForm.value.so.recommendation || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" })))
onMounted(async () => { await loadTaskConfig(); loadDraft(); await loadSavedAnswer(); draftReady.value = true }); watch(() => JSON.stringify(buildDraft()), persistDraft)
function createForm() { return { strengths: "", weaknesses: "", opportunities: "", threats: "" } }
function createStrategyForm() { return { so: createStrategyRow(), wo: createStrategyRow(), st: createStrategyRow(), wt: createStrategyRow() } }
@ -300,7 +301,7 @@ function createFinalForm() { return { priorities: Object.fromEntries(strategyDef
function parseArray(value, fallback) { if (Array.isArray(value)) return value.length ? value : fallback; if (!value) return fallback; try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback } catch (error) { const list = String(value).split(/[,,|]/).map((item) => item.trim()).filter(Boolean); return list.length ? list : fallback } }
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null } catch (error) { taskConfig.value = null } }
async function loadSavedAnswer() { try { const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data; if (answer?.step1Answer) { restoreOutcome(answer.step1Answer, form); stepOnePassed.value = true; clearDraft() } if (answer?.step2Answer) { restoreOutcome(answer.step2Answer, supplementForm, "supplements"); stepTwoPassed.value = true } if (answer?.step3Answer) { restoreStrategyOutcome(answer.step3Answer); stepThreePassed.value = true } if (answer?.step4Answer) restoreFinalOutcome(answer.step4Answer); if (Number(answer?.currentStep) > 0) currentStep.value = Number(answer.currentStep) } catch (error) { /* Keep local draft when unavailable. */ } }
function restoreOutcome(value, target, field = "swot") { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; target.value = { ...createForm(), ...(parsed?.[field] || parsed || {}) }; if (field === "swot" && parsed?.choiceAnswers && typeof parsed.choiceAnswers === "object") stepOneChoiceAnswers.value = parsed.choiceAnswers } catch (error) { target.value = createForm() } }
function restoreOutcome(value, target, field = "swot") { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; target.value = { ...createForm(), ...(parsed?.[field] || parsed || {}) }; if (field === "swot") { const structured = parsed?.choiceAnswers && typeof parsed.choiceAnswers === "object" ? parsed.choiceAnswers : null; if (structured && Object.keys(structured).length) stepOneChoiceAnswers.value = structured; else { /* 兜底:工具栏 DOM 快照 legacy-training-snapshot-v3,教师端评阅预览返回的就是这类数据 */ const legacyAnswers = parseLegacySnapshotChoiceAnswers(parsed, stepOneQuestions.value); if (legacyAnswers) stepOneChoiceAnswers.value = legacyAnswers } } } catch (error) { target.value = createForm() } }
function restoreStrategyOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const source = parsed?.strategies || parsed || {}; strategyForm.value = Object.fromEntries(strategyDefinitions.map((row) => [row.key, { ...createStrategyRow(), ...(source[row.key] || {}) }])) } catch (error) { strategyForm.value = createStrategyForm() } }
function restoreFinalOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const source = parsed?.decision || parsed || {}; const priorities = source.priorities || {}; finalForm.value = { ...createFinalForm(), ...source, priorities: Object.fromEntries(strategyDefinitions.map((row) => [row.key, { priority: null, reason: "", ...(priorities[row.key] || {}) }])) } } catch (error) { finalForm.value = createFinalForm() } }
function getCombinedLogic(row) { return [{ label: `${row.firstLabel}(第1步)· `, value: form.value[row.first] }, { label: `${row.firstLabel}(第2步补充)· `, value: supplementForm.value[row.first] }, { label: `${row.secondLabel}(第1步)· `, value: form.value[row.second] }, { label: `${row.secondLabel}(第2步补充)· `, value: supplementForm.value[row.second] }] }

@ -268,6 +268,7 @@ import useUserStore from "@/store/modules/user"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import TrainingStepOneChoiceQuestions from "@/views/components/TrainingStepOneChoiceQuestions.vue"
import { parseLegacySnapshotChoiceAnswers, parseStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"
const TASK_KEY = "industry-demand-analysis"
const { proxy } = getCurrentInstance()
@ -311,6 +312,8 @@ const taskBackground = computed(() => taskConfig.value?.background || defaultTas
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals))
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement)
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS))
/* 知识自测题目(含 id),legacy 快照回显时按题目顺序还原选项 */
const stepOneQuestions = computed(() => parseStepOneChoiceQuestions(taskConfig.value?.stepOneQuestions))
const progressItems = computed(() => pestRows.map((row) => ({ text: `${conclusionForm.value.summary ? "已总结" : analysisForm.value[row.key].opportunities ? "已分析" : supplementForm.value[row.key] ? "已补充" : form.value[row.key] ? "已归类" : "待填写"}:${row.label}`, status: conclusionForm.value.summary || analysisForm.value[row.key].opportunities || supplementForm.value[row.key] || form.value[row.key] ? "done" : "" })))
onMounted(async () => { await loadTaskConfig(); loadDraft(); await loadSavedAnswer(); draftReady.value = true })
@ -332,7 +335,7 @@ async function loadSavedAnswer() {
if (Number(answer?.currentStep) > 0) currentStep.value = Number(answer.currentStep)
} catch (error) { /* Keep the local draft when the answer endpoint is unavailable. */ }
}
function restoreOutcome(value, target, field = "pest") { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; target.value = { ...createForm(), ...(parsed?.[field] || parsed || {}) }; if (field === "pest" && parsed?.choiceAnswers && typeof parsed.choiceAnswers === "object") stepOneChoiceAnswers.value = parsed.choiceAnswers } catch (error) { target.value = createForm() } }
function restoreOutcome(value, target, field = "pest") { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; target.value = { ...createForm(), ...(parsed?.[field] || parsed || {}) }; if (field === "pest") { const structured = parsed?.choiceAnswers && typeof parsed.choiceAnswers === "object" ? parsed.choiceAnswers : null; if (structured && Object.keys(structured).length) stepOneChoiceAnswers.value = structured; else { /* 兜底:工具栏 DOM 快照 legacy-training-snapshot-v3,教师端评阅预览返回的就是这类数据 */ const legacyAnswers = parseLegacySnapshotChoiceAnswers(parsed, stepOneQuestions.value); if (legacyAnswers) stepOneChoiceAnswers.value = legacyAnswers } } } catch (error) { target.value = createForm() } }
function restoreAnalysisOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; const source = parsed?.analysis || parsed || {}; analysisForm.value = Object.fromEntries(pestAnalysisRows.map((row) => [row.key, { ...createAnalysisRow(), ...(source[row.key] || {}) }])) } catch (error) { analysisForm.value = createAnalysisForm() } }
function restoreConclusionOutcome(value) { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; conclusionForm.value = { ...createConclusionForm(), ...(parsed?.conclusion || parsed || {}) } } catch (error) { conclusionForm.value = createConclusionForm() } }
function buildStepOneOutcome() { return { title: taskTitle.value, pest: { ...form.value }, choiceAnswers: { ...stepOneChoiceAnswers.value } } }

@ -22,7 +22,13 @@
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-export="exportOutcome" />
<!--
必须把页面自身的提交/重置传给工具栏:
页面没有“提交任务”按钮,工具栏不传 on-submit 时提交会走 DOM 快照兜底,
只写 step1Answer 的整页快照,第 4 步内容永远存不进后端,教师端评阅就看不到。
-->
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetExample"
:on-export="exportOutcome" />
</section>
<section class="task-card">
@ -321,6 +327,7 @@ import { getTrainingTaskByKey } from "@/api/trainingTask"
import { checkNewProductSurveyStepFour, checkNewProductSurveyStepOne, checkNewProductSurveyStepThree, checkNewProductSurveyStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import useUserStore from "@/store/modules/user"
import { isExternal } from "@/utils/validate"
import { parseLegacySnapshotChoiceAnswers, parseLegacySnapshotTailTextareas, parseStepOneChoiceQuestions } from "@/utils/stepOneChoiceQuestions"
import { extractTrainingStepNames } from "@/views/training/taskKeyMap"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
@ -441,6 +448,9 @@ const getLocalDraftKey = () => {
return `${TASK_KEY}:draft:${userKey}`
}
/* 知识自测题目(含 id),legacy 快照回显时需要按题目顺序把选项还原到题目 id 上 */
const stepOneQuestions = computed(() => parseStepOneChoiceQuestions(taskConfig.value?.stepOneQuestions))
const currentStudyTip = computed(() => {
const tips = {
1: "当前步骤建议:先从案例或电商详情页中拆出核心层、有形层、延伸层,不要把功能和用户利益混在一起。",
@ -466,7 +476,7 @@ onMounted(async () => {
})
watch(
[classifications, checkResult, step2Form, step3Rows, step4Form, currentStep],
[classifications, checkResult, stepOneChoiceAnswers, step2Form, step3Rows, step4Form, currentStep],
() => persistDraft(),
{ deep: true }
)
@ -539,7 +549,8 @@ function applySavedAnswer(value) {
}
loaded = applyStep2Answer(parsed.step2Answer) || loaded
loaded = applyStep3Answer(parsed.step3Answer) || loaded
loaded = applyStep4Answer(parsed.step4Answer) || loaded
/* 第 4 步:结构化数据为空时,把 step1Answer 里的整页快照传给兜底解析 */
loaded = applyStep4Answer(parsed.step4Answer, step1Parsed) || loaded
currentStep.value = Math.min(Math.max(Number(parsed.currentStep || currentStep.value || 1), 1), 4)
return loaded
}
@ -564,9 +575,21 @@ function applyStep1Answer(value) {
return result
}, {})
classifications.value = restored
stepOneChoiceAnswers.value = parsed.choiceAnswers && typeof parsed.choiceAnswers === "object" ? parsed.choiceAnswers : {}
/*
* 知识自测答题回显:
* 1) 结构化 choiceAnswers(页面自身保存的 JSON)
* 2) 兜底:工具栏 DOM 快照 legacy-training-snapshot-v3(教师端评阅预览返回的就是这类数据)
*/
const savedChoiceAnswers = parsed.choiceAnswers && typeof parsed.choiceAnswers === "object" ? parsed.choiceAnswers : null
if (savedChoiceAnswers && Object.keys(savedChoiceAnswers).length) {
stepOneChoiceAnswers.value = savedChoiceAnswers
} else {
const legacyAnswers = parseLegacySnapshotChoiceAnswers(value, stepOneQuestions.value)
if (legacyAnswers) stepOneChoiceAnswers.value = legacyAnswers
}
checkResult.value = normalizeCheckResult(parsed.checkResult)
return Object.keys(restored).length > 0
/* 第一步表格 UI 已注销:只要知识自测有作答就视为有已保存内容,供上层清空本地草稿 */
return Object.keys(restored).length > 0 || Object.keys(stepOneChoiceAnswers.value).length > 0
}
function normalizeCheckResult(value) {
@ -695,14 +718,23 @@ function applyStep3Answer(value) {
return true
}
function applyStep4Answer(value) {
function applyStep4Answer(value, snapshotValue) {
const parsed = safeParse(value)
if (!parsed || typeof parsed !== "object") return false
const source = parsed.step4Form || parsed
const fields = Object.keys(createStep4Form())
const hasValue = fields.some((key) => source[key] !== undefined && source[key] !== null && source[key] !== "")
if (!hasValue) return false
step4Form.value = { ...createStep4Form(), ...pickFields(source, fields) }
if (hasValue) {
step4Form.value = { ...createStep4Form(), ...pickFields(source, fields) }
return true
}
/*
* 兜底:结构化数据为空时,从工具栏 DOM 快照(存在 step1Answer 里)尾部 textarea 还原第 4 步
* (学生端第 4 步只在本地草稿里、后端 step4Answer 为空时,教师端评阅才看得到)
*/
const snapshotFields = parseLegacySnapshotTailTextareas(snapshotValue || parsed, fields, "/foundation/new-product-survey")
if (!snapshotFields) return false
step4Form.value = { ...createStep4Form(), ...snapshotFields }
return true
}

@ -440,10 +440,12 @@ async function refreshStudentPreview() {
});
previewKey.value = nextPreviewKey;
studentPreviewSrc.value = `${window.location.pathname}#/task-review-preview?taskKey=${encodeURIComponent(taskKey)}&reviewPreviewKey=${encodeURIComponent(nextPreviewKey)}`;
} catch (_) {
} catch (error) {
if (requestVersion !== previewRequestVersion) return;
previewKey.value = "";
studentPreviewError.value = "该教学班的实训案例加载失败,未展示通用案例";
/* 把真实失败原因透出给教师,避免只有一句通用文案没法排查 */
console.error("[task-review] 实训案例加载失败", error);
studentPreviewError.value = `该教学班的实训案例加载失败:${error?.message || "未知错误"}`;
}
}
function formatScore(score) {

@ -21,6 +21,7 @@
</template>
<script setup>
import { ElMessage } from "element-plus";
import { getTrainingReviewPreviewContext } from "@/utils/trainingReviewPreview";
import { studentTaskPreviewComponentByKey, studentTaskPreviewPropsByKey } from "./studentTaskPreviewMap";
@ -99,20 +100,56 @@ function guardPreviewInteraction(event) {
if (target.closest("input, textarea, select, [contenteditable='true'], .el-input, .el-textarea, .el-select, .el-checkbox, .el-radio, .el-switch")) {
event.preventDefault();
event.stopImmediatePropagation();
notifyReadonly();
return;
}
const action = target.closest("button, a, [role='button'], [role='tab'], .el-step");
if (action && !isStepNavigation(action)) {
event.preventDefault();
event.stopImmediatePropagation();
notifyReadonly();
}
}
/*
* 步骤导航容器白名单:
* 只靠文案(第N步/上一步/下一步)判断太窄——行业需求分析、竞争环境分析等页面的步骤胶囊
* 文案是「01 PEST宏观环境分析 点击切换 →」,不匹配任何关键词,会被当成普通按钮拦掉,
* 表现为「点了没反应、也不报错」。凡落在已知步骤导航容器里的控件一律放行。
*/
const PREVIEW_STEP_NAV_CONTAINER = [
".step-indicator",
".step-tabs",
".step-nav",
".training-step-nav",
".comprehensive-step-nav",
".comprehensive-step-strip",
".flow-steps",
".flow-rail",
".training-intro__steps",
".top_item",
].join(",");
function isStepNavigation(element) {
if (element.matches("[role='tab'], .el-step")) return true;
if (element.closest(PREVIEW_STEP_NAV_CONTAINER)) return true;
const label = String(element.getAttribute("aria-label") || "");
const text = `${label} ${String(element.textContent || "")}`.replace(/\s+/g, " ").trim();
return /(上一步|下一步|第\s*\d+\s*步|切换.*步骤|步骤.*切换)/.test(text);
return /(上一步|下一步|第\s*\d+\s*步|步骤\s*\d+|切换.*步骤|步骤.*切换|点击切换|当前步骤)/.test(text);
}
/* 只读态被拦截的操作要给提示,避免教师以为页面坏了 */
let lastReadonlyHintAt = 0;
function notifyReadonly() {
const now = Date.now();
if (now - lastReadonlyHintAt < 2500) return;
lastReadonlyHintAt = now;
ElMessage({
message: "任务评阅预览为只读模式,不能修改学生作答;步骤切换不受影响",
type: "info",
duration: 2000,
showClose: true,
});
}
</script>

Loading…
Cancel
Save