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/product/components/ProductStandardTrainingPage...

170 lines
23 KiB
Vue

<template>
<div class="product-standard-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge"><el-icon><Document /></el-icon> {{ badgeText }}</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</div>
<nav v-if="hasConfiguredSteps" class="step-indicator" :aria-label="`${taskTitle}步骤`">
<template v-for="(step, index) in configuredSteps" :key="`${step}-${index}`">
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]" :aria-current="activeStep === index + 1 ? 'step' : undefined" :aria-label="`第${index + 1}步:${step}`" @click="setCurrentStep(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong class="step-name" :title="step">{{ step }}</strong><span class="step-action">{{ activeStep === index + 1 ? "当前步骤" : "点击切换" }} <span aria-hidden="true">→</span></span></span>
</button>
<span v-if="index < configuredSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section class="step-card">
<div class="step-title-row">
<div>
<p v-if="hasConfiguredSteps" class="section-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="step-content">
<slot v-if="hasCustomContent" name="step-content" :active-step="activeStep" :active-step-name="activeStepName" :has-configured-steps="hasConfiguredSteps" />
<template v-else>
<p v-if="page.keyPoints?.length && isFirstStep" class="step-desc">请围绕以下内容组织需求说明书,确保文档能够支撑设计、开发、测试和验收协作。</p>
<div v-if="page.keyPoints?.length && isFirstStep" class="key-point-grid">
<span v-for="item in page.keyPoints" :key="item">{{ item }}</span>
</div>
<label v-for="field in visibleFields" :key="field.key" class="field-block">
<span>{{ field.label }}</span>
<el-input v-model="form[field.key]" :type="field.type === 'textarea' ? 'textarea' : 'text'" :autosize="field.type === 'textarea' ? { minRows: 6, maxRows: 11 } : undefined" :placeholder="field.placeholder" />
</label>
<div v-if="showWorkTable" class="table-panel">
<div class="panel-title"><h4>{{ page.tableTitle || "实训工作表" }}</h4><p>请结合产品对象填写对应内容,形成可提交的实训成果。</p></div>
<div class="table-wrap">
<table>
<thead><tr><th>项目</th><th>说明</th><th>学生填写</th></tr></thead>
<tbody><tr v-for="row in workRows" :key="row.name"><th scope="row">{{ row.name }}</th><td>{{ row.description }}</td><td><el-input v-model="row.answer" type="textarea" :autosize="{ minRows: 3, maxRows: 6 }" placeholder="填写本项分析内容" /></td></tr></tbody>
</table>
</div>
</div>
<p v-if="!visibleFields.length && !showWorkTable" class="step-desc">请根据本步骤要求梳理前述内容,确认信息完整后继续下一步。</p>
</template>
</div>
</section>
<div v-if="hasConfiguredSteps" class="step-navigation">
<button type="button" class="btn-nav" :disabled="activeStep === 1" @click="goPreviousStep"><el-icon><ArrowLeft /></el-icon>上一步</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button>
<button type="button" class="btn-nav btn-primary" :disabled="activeStep === configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button>
</div>
<div class="training-actions">
<el-button class="training-action training-action--submit" :loading="saving" @click="submitTask">提交任务</el-button>
<el-button class="training-action training-action--reset" :loading="saving" @click="resetTask">清空填写</el-button>
</div>
</section>
</main>
<TrainingAiSidebar :study-tip="`当前任务建议:围绕“${taskTitle}”先明确产品对象,再按配置步骤补全实训内容。`" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref, useSlots } from "vue";
import { ArrowLeft, ArrowRight, CircleCheck, Document, Download } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { getStudentTrainingPage } from "@/views/training/studentTrainingPages";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const props = defineProps({ taskKey: { type: String, required: true }, title: { type: String, default: "" }, background: { type: String, default: "" }, requirement: { type: String, default: "" }, goals: { type: Array, default: () => [] }, badgeText: { type: String, default: "核心实训 · 产品规划与设计" } });
const emit = defineEmits(["save", "submit", "reset"]);
const slots = useSlots();
const { proxy } = getCurrentInstance();
const taskConfig = ref(null);
const saving = ref(false);
const activeStep = ref(1);
const page = computed(() => getStudentTrainingPage(props.taskKey));
const form = ref(createForm());
const workRows = ref(createRows());
const hasCustomContent = computed(() => Boolean(slots["step-content"]));
const taskTitle = computed(() => taskConfig.value?.taskName || props.title || page.value.title);
const taskBackground = computed(() => taskConfig.value?.background || props.background || page.value.background);
const taskRequirement = computed(() => taskConfig.value?.requirements || props.requirement || page.value.task);
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, props.goals.length ? props.goals : page.value.goals));
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps));
const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0);
const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || page.value.title);
const fieldsPerStep = computed(() => hasConfiguredSteps.value ? Math.max(1, Math.ceil((page.value.fields?.length || 0) / configuredSteps.value.length)) : page.value.fields?.length || 0);
const visibleFields = computed(() => {
const fields = page.value.fields || [];
if (!hasConfiguredSteps.value) return fields;
const start = (activeStep.value - 1) * fieldsPerStep.value;
return fields.slice(start, start + fieldsPerStep.value);
});
const showWorkTable = computed(() => Boolean(workRows.value.length) && (!hasConfiguredSteps.value || activeStep.value === configuredSteps.value.length));
const isFirstStep = computed(() => !hasConfiguredSteps.value || activeStep.value === 1);
const progressItems = computed(() => configuredSteps.value.map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
function createForm() { return Object.fromEntries((page.value.fields || []).map((field) => [field.key, ""])); }
function createRows() { return (page.value.tableRows || []).map(([name, description]) => ({ name, description, answer: "" })); }
function normalizeActiveStep(value) { if (!configuredSteps.value.length) return 1; return Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
function buildProgress() { return { activeStep: activeStep.value, form: form.value, workRows: workRows.value, updatedAt: new Date().toISOString() }; }
function applyProgress(value) { if (!value || typeof value !== "object") return; activeStep.value = normalizeActiveStep(value.activeStep); form.value = { ...createForm(), ...(value.form || {}) }; workRows.value = createRows().map((row, index) => ({ ...row, answer: value.workRows?.[index]?.answer || "" })); }
onMounted(async () => { await loadTaskConfig(); await loadSavedProgress(); });
async function loadTaskConfig() { try { const res = await getTrainingTaskByKey(props.taskKey); taskConfig.value = res?.data || null; activeStep.value = normalizeActiveStep(activeStep.value); } catch (error) { taskConfig.value = null; activeStep.value = 1; } }
async function loadSavedProgress() { try { const res = await getStudentTrainingAnswer(props.taskKey); const raw = res?.data?.step1Answer; if (raw) applyProgress(JSON.parse(raw)); } catch (error) { /* The training task remains available without previous progress. */ } }
async function persistProgress(saveAction = "SAVE", silent = false) { await saveStudentTrainingAnswer(props.taskKey, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()) }); if (!silent) proxy?.$modal?.msgSuccess(saveAction === "SUBMIT" ? "提交成功" : "保存成功"); }
function setCurrentStep(step) { activeStep.value = normalizeActiveStep(step); persistProgress("SAVE", true).catch(() => {}); }
function goPreviousStep() { setCurrentStep(activeStep.value - 1); }
function goNextStep() { setCurrentStep(activeStep.value + 1); }
async function saveCurrentProgress() { if (saving.value) return; saving.value = true; try { if (hasCustomContent.value) { emit("save"); await persistProgress("SAVE", true); } else { await persistProgress(); } } finally { saving.value = false; } }
async function submitTask() { if (saving.value) return; saving.value = true; try { if (hasCustomContent.value) { emit("submit"); await persistProgress("SAVE", true); } else { await persistProgress("SUBMIT"); } } finally { saving.value = false; } }
async function resetTask() { if (saving.value) return; saving.value = true; try { if (hasCustomContent.value) emit("reset"); form.value = createForm(); workRows.value = createRows(); activeStep.value = 1; await persistProgress("RESET", hasCustomContent.value); if (!hasCustomContent.value) proxy?.$modal?.msgSuccess("清空填写成功"); } finally { saving.value = false; } }
function exportOutcome() { const blob = new Blob([JSON.stringify(buildProgress(), null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${taskTitle.value}-实训成果.json`; link.click(); URL.revokeObjectURL(url); }
</script>
<style scoped lang="scss">
.product-standard-page { display:grid; grid-template-columns:minmax(720px,1fr) 340px; gap:24px; min-height:calc(100vh - 76px); padding:24px 24px 32px; color:#1e3a5f; background:#ffffff; }.training-core { min-width:0; padding:28px; border:1px solid #cfe2f5; border-radius:36px; background:#f0f6fd; box-shadow:inset 0 1px 0 #cfe2f5,0 22px 48px rgba(0,0,0,0.08); }.task-header { margin-bottom:24px; }.task-badge { display:inline-flex; align-items:center; gap:8px; min-height:28px; padding:4px 14px; border:1px solid #cfe2f5; border-radius:999px; color:#2c4a6a; background:#e3f2fd; font-size:var(--student-fs-caption); font-weight:700; }.task-title { margin:12px 0 0; color:#1e3a5f; font-size:var(--student-fs-display); font-weight:900; line-height:1.3; }
.task-card,.step-card { border:1px solid #cfe2f5; border-radius:26px; background:#f0f6fd; }.task-card { margin-top:28px; padding:18px 22px 22px; }.workbench-head { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:22px; padding-bottom:18px; border-bottom:1px solid rgba(0,180,255,.2); }.section-eyebrow { margin:0; color:#1565c0; font-size:var(--student-fs-caption); font-weight:800; line-height:1.4; }.workbench-head h3 { margin:4px 0 8px; color:#1e3a5f; font-size:var(--student-fs-h1); font-weight:800; line-height:1.3; }.workbench-description,.step-desc { margin:0; color:#2c4a6a; font-size:var(--student-fs-body); line-height:1.8; }.workbench-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; gap:10px; flex-shrink:0; }.outline-action,.btn-nav { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:40px; padding:0 18px; border:1px solid #cfe2f5; border-radius:999px; color:#1e3a5f; background:#e3f2fd; font:inherit; font-size:var(--student-fs-button); font-weight:800; cursor:pointer; }.outline-action:hover,.outline-action:focus-visible,.btn-nav:hover:not(:disabled),.btn-nav:focus-visible { border-color:#cfe2f5; background:#bbdefb; outline:none; transform:translateY(-1px); }
.step-indicator { display:flex; align-items:center; gap:12px; width:calc(100% + 24px); min-height:0; margin:0 -12px 32px; padding:18px 22px; overflow-x:auto; border:1px solid #cfe2f5; border-radius:18px; background:linear-gradient(110deg,#e3f2fd,#e3f2fd); box-shadow:inset 0 1px 0 #cfe2f5; }.step-tab { display:flex; flex:1 0 145px; align-items:center; min-height:66px; padding:8px 10px; border:1px solid #cfe2f5; border-radius:8px; color:#5a7a9a; background:#e3f2fd; font:inherit; font-size:var(--student-fs-body); font-weight:800; text-align:left; cursor:pointer; transition:transform .2s,color .2s,background .2s,border-color .2s,box-shadow .2s; }.step-tab:hover,.step-tab:focus-visible { transform:translateY(-2px); border-color:#cfe2f5; color:#1e3a5f; background:#bbdefb; box-shadow:0 8px 18px rgba(0,0,0,0.08); outline:none; }.step-tab.active { border-color:#cfe2f5; color:#1e3a5f; background:linear-gradient(115deg,rgba(4,109,155,.78),rgba(5,62,102,.64)); box-shadow:0 0 18px #cfe2f5,inset 0 1px 0 #cfe2f5; }.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid #cfe2f5; border-radius:50%; color:#5a7a9a; background:#e3f2fd; box-shadow:inset 0 0 12px #cfe2f5; font-size:var(--student-fs-caption); font-weight:900; letter-spacing:.04em; }.step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }.step-copy strong { display:-webkit-box; overflow:hidden; color:inherit; font-size:var(--student-fs-body); line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.step-copy span { color:#1565c0; font-size:var(--student-fs-caption); font-weight:800; letter-spacing:.06em; }.step-tab.active .step-marker { border-color:#1565c0; color:#1e3a5f; background:linear-gradient(135deg,#1e88e5,#1565c0); box-shadow:0 0 0 5px #cfe2f5,0 0 18px #cfe2f5; }.step-tab.complete { color:#2c4a6a; }.step-tab.complete .step-marker { border-color:#43a047; color:#ffffff; background:#43a047; box-shadow:0 0 12px #43a047; }.step-connector { position:relative; flex:0 1 64px; min-width:22px; height:2px; margin:0 14px; color:transparent; background:#cfe2f5; }.step-connector::after { position:absolute; top:50%; right:-1px; width:6px; height:6px; border-top:1px solid #1565c0; border-right:1px solid #1565c0; content:""; transform:translateY(-50%) rotate(45deg); }.step-connector.complete { background:linear-gradient(90deg,#43a047,#1e88e5); box-shadow:0 0 9px #26c6da; }.step-connector.complete::after { border-color:#26c6da; }.step-card { margin-top:32px; padding:18px 22px 22px; }.step-indicator + .step-card,.workbench-head + .step-card { margin-top:0; }.step-title-row { margin-bottom:14px; }.step-title-row h3 { margin:4px 0 0; color:#1e3a5f; font-size:var(--student-fs-h2); font-weight:900; line-height:1.3; }.key-point-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:18px 0; }.key-point-grid span,.table-panel { border:1px solid #cfe2f5; border-radius:16px; background:#e3f2fd; }.key-point-grid span { padding:12px 14px; color:#2c4a6a; font-size:var(--student-fs-body); font-weight:700; line-height:1.55; }.field-block { display:block; margin:16px 0 0; }.field-block span { display:block; margin-bottom:8px; color:#2c4a6a; font-size:var(--student-fs-body); font-weight:800; line-height:1.6; }.field-block :deep(.el-input__wrapper),.field-block :deep(.el-textarea__inner),.table-wrap :deep(.el-textarea__inner) { border:1px solid #cfe2f5; border-radius:14px; box-shadow:none; background:#ffffff; }.field-block :deep(.el-input__wrapper) { min-height:42px; padding:0 14px; }.field-block :deep(.el-input__inner),.field-block :deep(.el-textarea__inner),.table-wrap :deep(.el-textarea__inner) { color:#1e3a5f; font-size:var(--student-fs-body); }.field-block :deep(.el-textarea__inner),.table-wrap :deep(.el-textarea__inner) { min-height:110px !important; padding:12px 14px; line-height:1.65; }.table-panel { margin-top:22px; padding:16px; }.panel-title { margin-bottom:12px; }.panel-title h4 { margin:0 0 4px; color:#1e3a5f; font-size:var(--student-fs-h1); font-weight:90
@media (max-width:1100px) { .product-standard-page { grid-template-columns:1fr; } } @media (max-width:900px) { .product-standard-page { padding:14px; }.training-core { padding:18px; border-radius:24px; }.workbench-head,.workbench-actions,.step-navigation { flex-direction:column; align-items:stretch; }.workbench-actions,.outline-action,.btn-nav,.training-actions :deep(.training-action) { width:100%; }.step-indicator { align-items:stretch; flex-direction:column; width:100%; margin-right:0; margin-left:0; overflow:visible; border-radius:20px; }.step-tab { flex:0 0 auto; width:100%; }.step-connector { flex:0 0 22px; width:2px; height:22px; margin-left:29px; text-align:center; writing-mode:vertical-rl; }.key-point-grid { grid-template-columns:1fr; } }
/* Standard page step navigation */
.step-indicator { position:relative; display:flex; align-items:center; gap:0; width:calc(100% + 24px); margin:0 -12px 32px; padding:18px 22px; overflow-x:auto; border:1px solid #cfe2f5; border-radius:18px; background:linear-gradient(90deg,#e3f2fd,#ffffff),rgba(0,0,0,0); box-shadow:inset 0 1px 0 #cfe2f5; scrollbar-width:thin; }
.step-tab { display:flex; flex:1 0 145px; align-items:center; min-width:0; min-height:66px; padding:8px 10px; border:1px solid #cfe2f5; border-radius:8px; color:#5a7a9a; background:#e3f2fd; box-shadow:inset 0 1px 0 #cfe2f5; font:inherit; font-size:var(--student-fs-body); font-weight:800; text-align:left; cursor:pointer; transition:transform .2s ease,color .2s ease,border-color .2s ease,background .2s ease,box-shadow .2s ease; }
.step-tab:hover { transform:translateY(-2px); border-color:#cfe2f5; color:#1e3a5f; background:#bbdefb; box-shadow:0 7px 18px rgba(0,0,0,0.1),inset 0 1px 0 #cfe2f5; }
.step-tab:hover .step-action { color:#1565c0; }
.step-tab:focus-visible { outline:2px solid #cfe2f5; outline-offset:2px; }
.step-tab.active { border-color:#cfe2f5; color:#1e3a5f; background:linear-gradient(115deg,rgba(4,109,155,.78),rgba(5,62,102,.64)); box-shadow:0 0 18px #cfe2f5,inset 0 1px 0 #cfe2f5; }
.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid #cfe2f5; border-radius:50%; color:#5a7a9a; background:#e3f2fd; box-shadow:inset 0 0 12px #cfe2f5; font-size:var(--student-fs-caption); font-weight:900; letter-spacing:.04em; }
.step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }
.step-name { display:-webkit-box; overflow:hidden; color:inherit; font-size:var(--student-fs-body); font-weight:800; line-height:1.25; text-overflow:ellipsis; -webkit-box-orient:vertical; -webkit-line-clamp:2; }
.step-action { color:#1565c0; font-size:var(--student-fs-caption); font-weight:800; letter-spacing:.06em; line-height:1; }
.step-tab.active .step-marker { border-color:#1565c0; color:#1e3a5f; background:linear-gradient(135deg,#1e88e5,#1565c0); box-shadow:0 0 0 5px #cfe2f5,0 0 18px #cfe2f5; }
.step-tab.complete .step-marker { border-color:#43a047; color:#ffffff; background:#43a047; box-shadow:0 0 12px #43a047; }
.step-connector { position:relative; flex:0 1 40px; min-width:18px; height:2px; margin:0 8px; overflow:visible; background:#cfe2f5; }
.step-connector::after { position:absolute; top:50%; right:-1px; width:6px; height:6px; border-top:1px solid #1565c0; border-right:1px solid #1565c0; content:""; transform:translateY(-50%) rotate(45deg); }
.step-tab.complete + .step-connector { background:linear-gradient(90deg,#43a047,#1e88e5); box-shadow:0 0 9px #26c6da; }
.step-tab.complete + .step-connector::after { border-color:#26c6da; }
@media (max-width:900px) { .step-indicator { align-items:stretch; flex-direction:column; width:100%; margin-right:0; margin-left:0; overflow:visible; border-radius:20px; } .step-tab { flex:0 0 auto; width:100%; } .step-connector { flex:0 0 20px; min-width:0; width:2px; height:20px; margin-left:29px; } }
.workbench-head--actions-only { justify-content:flex-end; margin-bottom:18px; padding-bottom:0; border-bottom:0; }
</style>