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/structure.vue

1686 lines
63 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="architecture-page student-training-shell">
<main class="architecture-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul>
<li v-for="goal in taskGoals" :key="goal">{{ goal }}</li>
</ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="checkAndSaveStepFour"
:on-export="exportOutcome" />
</section>
<section class="architecture-workbench">
<nav class="step-indicator" aria-label="产品架构设计步骤">
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
<button type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
@click="switchStep(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong class="step-name">{{ step }}</strong></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section v-if="currentStep === 1" class="architecture-step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 01</p>
<h3>{{ trainingSteps[0] }}</h3>
</div><span class="step-chip">拖拽排序判题</span>
</div>
<p class="step-desc">智能硬件产品通常采用模块化分层架构,从物理硬件到用户功能分为 4
层。请将卡片拖拽为<strong>从顶层到底层</strong>的正确顺序(顶层在上、底层在下);排序正确后才能进入下一步。</p>
<div class="architecture-workspace">
<div class="order-rail" aria-hidden="true"><span>顶层</span><i></i><span>底层</span></div>
<div class="layer-list" role="list" aria-label="产品架构层级排序">
<article v-for="(layer, index) in layerOrder" :key="layer.id" class="layer-card"
:class="{ dragging: dragLayerId === layer.id }" draggable="true" role="listitem"
@dragstart="onDragStart(layer.id)" @dragend="dragLayerId = ''" @dragover.prevent
@drop="onDrop(layer.id)">
<span class="layer-position">{{ index + 1 }}</span>
<span class="drag-handle" title="拖拽排序">⋮⋮</span>
<div class="layer-copy">
<h4>{{ layer.name }}</h4>
<p>{{ layer.description }}</p>
</div>
<div class="move-actions">
<button type="button" :disabled="index === 0 || checking" title="上移"
@click="moveLayer(index, -1)">↑</button>
<button type="button" :disabled="index === layerOrder.length - 1 || checking"
title="下移" @click="moveLayer(index, 1)">↓</button>
</div>
</article>
</div>
</div>
<p v-if="checkResult" class="result-message" :class="{ success: checkResult.allCorrect }">{{
checkResult.message ||
(checkResult.allCorrect ? '排序正确,已解锁下一步。' : `当前仅
${checkResult.correctPositionCount}/${checkResult.totalCount}
个位置正确,请继续调整。`) }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="checking" @click="shuffleLayers"><el-icon>
<RefreshLeft />
</el-icon>重新打乱</button>
</div>
</section>
<section v-else-if="currentStep === 2" class="architecture-step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 02</p>
<h3>{{ trainingSteps[1] }}</h3>
</div><span class="step-chip">操作链路排序</span>
</div>
<p class="step-desc">产品运行时,用户操作产生的信号会经过硬件感知、驱动传递、算法处理和应用功能触发,最终再由硬件产生效果。请将以下动作按一次完整运行链路的正确顺序排列。</p>
<div class="flow-workspace">
<div class="flow-caption"><span>操作发起</span><i></i><span>效果反馈</span></div>
<div class="flow-list" role="list" aria-label="产品运行操作链路排序">
<article v-for="(action, index) in flowOrder" :key="action.id" class="flow-card"
:class="{ dragging: dragFlowId === action.id }" draggable="true" role="listitem"
@dragstart="onFlowDragStart(action.id)" @dragend="dragFlowId = ''" @dragover.prevent
@drop="onFlowDrop(action.id)">
<span class="layer-position">{{ index + 1 }}</span>
<span class="drag-handle" title="拖拽排序">⋮⋮</span>
<div class="layer-copy">
<h4>{{ action.name }}</h4>
<p>{{ action.description }}</p>
</div>
<div class="move-actions">
<button type="button" :disabled="index === 0 || checking" title="上移"
@click="moveFlow(index, -1)">↑</button>
<button type="button" :disabled="index === flowOrder.length - 1 || checking"
title="下移" @click="moveFlow(index, 1)">↓</button>
</div>
</article>
</div>
</div>
<p v-if="stepTwoCheckResult" class="result-message"
:class="{ success: stepTwoCheckResult.allCorrect }">{{
stepTwoCheckResult.message || (stepTwoCheckResult.allCorrect ? '排序正确,已解锁下一步。' : `当前仅
${stepTwoCheckResult.correctPositionCount}/${stepTwoCheckResult.totalCount} 个位置正确,请继续调整。`) }}
</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="checking" @click="shuffleFlow"><el-icon>
<RefreshLeft />
</el-icon>重新打乱</button>
</div>
</section>
<section v-else-if="currentStep === 3" class="architecture-step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 03</p>
<h3>{{ trainingSteps[2] }}</h3>
</div><span class="step-chip">场景链路分析</span>
</div>
<p class="step-desc">根据本实训案例资料,至少选择 3 个典型的工作场景,分析产品架构各层级在场景中的具体工作。每一行需完整填写,系统会将这些内容作为后续架构方案设计的依据。
</p>
<div class="scene-toolbar">
<span>已完成 {{ completedScenarioCount }} 个场景 <em>(至少 3 个,空白行不参与校验)</em></span>
<div class="scene-toolbar__actions">
<button type="button" class="btn-nav" :disabled="scenarioRows.length >= 10 || checking"
@click="addScenarioRow">增加一行</button>
<button type="button" class="btn-nav" :disabled="scenarioRows.length <= 3 || checking"
@click="removeScenarioRow">删除一行</button>
<button type="button" class="btn-nav" @click="exportOutcome"><el-icon>
<Download />
</el-icon>导出</button>
</div>
</div>
<div class="scenario-table-wrap">
<table class="scenario-table">
<thead>
<tr>
<th>序号</th>
<th>用户场景</th>
<th>硬件物理层<br><small>(感知什么)</small></th>
<th>驱动感知层<br><small>(传递什么)</small></th>
<th>算法处理层<br><small>(处理什么)</small></th>
<th>应用功能层<br><small>(触发什么)</small></th>
<th>驱动感知层<br><small>(输出什么)</small></th>
<th>硬件物理层<br><small>(产生什么效果)</small></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in scenarioRows" :key="row.id">
<td class="scenario-index">{{ index + 1 }}</td>
<td><el-input v-model="row.userScenario" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:用户说「小美,开灯」' : '填写典型用户场景'" /></td>
<td><el-input v-model="row.hardwareSense" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:麦克风阵列采集到声音信号' : '硬件感知到什么'" /></td>
<td><el-input v-model="row.driverTransmit" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:音频采集驱动将声音转为数字信号' : '驱动层传递什么'" /></td>
<td><el-input v-model="row.algorithmProcess" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:唤醒词检测与命令词匹配' : '算法层处理什么'" /></td>
<td><el-input v-model="row.applicationTrigger" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:触发语音开关灯功能' : '应用层触发什么'" /></td>
<td><el-input v-model="row.driverOutput" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:LED 调光驱动输出 PWM 信号' : '驱动层输出什么'" /></td>
<td><el-input v-model="row.hardwareEffect" type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="index === 0 ? '例如:灯珠点亮并输出亮度和色温' : '硬件产生什么效果'" /></td>
</tr>
</tbody>
</table>
</div>
<p v-if="stepThreeCheckResult" class="result-message"
:class="{ success: stepThreeCheckResult.valid }">{{
stepThreeCheckResult.message }}</p>
</section>
<section v-else-if="currentStep === 4" class="architecture-step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 04</p>
<h3>{{ trainingSteps[3] }}</h3>
</div><span class="step-chip">核心功能架构图</span>
</div>
<p class="step-desc">根据本实训案例资料,选取3个核心功能,画出这3个功能在四层架构中的逻辑调用关系图。要求画清楚应用功能层、算法处理层、驱动感知层、硬件物理层;每个功能从应用层出发,向下标注所调用的算法模块-驱动模块-硬件单元;用箭头标注信号流向。</p>
<section class="architecture-image-grid">
<article v-for="(image, index) in architectureImages" :key="image.key"
class="architecture-image-card"
:class="{ uploading: uploadingArchitectureImage === index }">
<div class="architecture-image-card__head"><span>架构图 {{ index + 1 }}</span><small>{{
image.url ? '已上传' :
'待上传' }}</small></div>
<label class="architecture-image-trigger">
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif"
:disabled="uploadingArchitectureImage !== null || checking"
@change="uploadArchitectureImage($event, index)" />
<el-icon>
<Upload />
</el-icon>
<strong>{{ uploadingArchitectureImage === index ? '上传中…' : image.url ? '替换架构图' : `上传架构图
${index + 1}`
}}</strong>
<span>PNG、JPG、WEBP 或 GIF</span>
</label>
<figure v-if="image.url" class="architecture-image-preview">
<el-image :src="image.url" :preview-src-list="[image.url]" fit="cover"
preview-teleported />
<figcaption><span>{{ image.name || `架构图 ${index + 1}` }}</span><button type="button"
@click="clearArchitectureImage(index)">移除</button></figcaption>
</figure>
</article>
</section>
<p v-if="stepFourCheckResult" class="result-message"
:class="{ success: stepFourCheckResult.valid }">{{
stepFourCheckResult.message }}</p>
</section>
<section v-else class="architecture-step-card placeholder-card">
<p class="section-eyebrow">Step {{ String(currentStep).padStart(2, '0') }}</p>
<h3>{{ trainingSteps[currentStep - 1] }}</h3>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
@click="previousStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(currentStep).padStart(2, "0") }} / {{
String(trainingSteps.length).padStart(2, "0") }}</span>
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary"
:disabled="saving" @click="nextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="先识别物理硬件、驱动感知、算法处理和应用功能各自职责,再从顶层到底层排序(顶层在上)。" reference-title="分层判断提示"
reference-text="· 硬件物理层:实体元器件与电路基础<br />· 驱动感知层:信号采集、转换与驱动<br />· 算法处理层:数据处理、识别与决策<br />· 应用功能层:用户可直接感知和操作的功能"
:progress-items="progressItems" />
</div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, CircleCheck, Download, Operation, RefreshLeft, Upload } from "@element-plus/icons-vue"
import { checkProductArchitectureDesignStepOne, checkProductArchitectureDesignStepTwo, checkProductArchitectureDesignStepThree, checkProductArchitectureDesignStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { SCENARIO_FIELDS, getCompletedScenarioCount, getRowsForScenarioValidation, getScenarioValidationMessage } from "./productArchitectureScenarioValidation.mjs"
const { proxy } = getCurrentInstance()
const TASK_KEY = "product-structure"
const DEFAULT_STEPS = ["智能硬件分层架构", "产品运行操作链路", "产品场景架构分析", "架构评审与优化"]
const LAYERS = [
{ id: "hardware-physical", name: "硬件物理层", description: "产品的物理基础,包含电源模块、主控芯片、传感器、LED灯珠等实体元器件。" },
{ id: "driver-sensing", name: "驱动感知层", description: "连接硬件与算法的中间层,负责信号采集、转换与输出驱动。" },
{ id: "algorithm-processing", name: "算法处理层", description: "数据处理与决策的核心层,负责语音识别、信号分析、控制策略等算法逻辑。" },
{ id: "application-function", name: "应用功能层", description: "面向用户的功能呈现层,是用户能直接感知和操作的功能集合。" },
]
const FLOW_ACTIONS = [
{ id: "user-action", name: "用户做了一个操作", description: "用户在产品或应用中发起一次操作。" },
{ id: "hardware-senses", name: "硬件感知到信号", description: "传感器或硬件模块捕获用户操作形成的输入信号。" },
{ id: "driver-transmits", name: "驱动层传递信号", description: "驱动与感知层完成信号采集、转换并向上层传递。" },
{ id: "algorithm-processes", name: "算法层处理数据", description: "算法层对输入数据进行识别、分析和决策。" },
{ id: "application-triggers", name: "应用层触发功能", description: "应用功能层依据算法结果触发对应的产品功能。" },
{ id: "driver-outputs", name: "驱动层输出指令", description: "驱动层将功能指令转化为可执行的控制输出。" },
{ id: "hardware-effects", name: "硬件产生效果", description: "硬件执行指令并形成用户可感知的最终效果。" },
]
const fallbackTask = {
title: "产品架构设计", background: "智能硬件产品采用模块化分层架构。通过本实训理解各层职责和层间关系,为后续功能与技术方案设计建立基础。",
goals: ["理解智能硬件的四层模块化架构", "区分各层的核心职责", "建立从物理硬件到用户功能的系统化思维"],
requirement: "将四层架构按「从顶层到底层」排序(顶层在上);判题通过后方可进入下一步。",
}
const taskConfig = ref(null)
const briefOpen = ref(true)
const currentStep = ref(1)
const layerOrder = ref(shuffle(duplicateLayers()))
const dragLayerId = ref("")
const checking = ref(false)
const stepOnePassed = ref(false)
const checkResult = ref(null)
const flowOrder = ref(shuffle(duplicateFlowActions()))
const dragFlowId = ref("")
const stepTwoPassed = ref(false)
const stepTwoCheckResult = ref(null)
const scenarioRows = ref(createScenarioRows(3))
const stepThreePassed = ref(false)
const stepThreeCheckResult = ref(null)
const architectureImages = ref(createArchitectureImages())
const uploadingArchitectureImage = ref(null)
const stepFourPassed = ref(false)
const stepFourCheckResult = ref(null)
const taskTitle = computed(() => taskConfig.value?.taskName || fallbackTask.title)
const taskBackground = computed(() => taskConfig.value?.background || fallbackTask.background)
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, fallbackTask.goals))
const taskRequirement = computed(() => taskConfig.value?.requirements || fallbackTask.requirement)
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS))
const progressItems = computed(() => trainingSteps.value.map((text, index) => ({ text, status: index + 1 < currentStep.value ? "done" : index + 1 === currentStep.value ? "doing" : "" })))
const completedScenarioCount = computed(() => getCompletedScenarioCount(scenarioRows.value))
const saving = computed(() => checking.value || uploadingArchitectureImage.value !== null)
onMounted(async () => { await loadTaskConfig(); await loadSavedProgress() })
function duplicateLayers() { return LAYERS.map((item) => ({ ...item })) }
function duplicateFlowActions() { return FLOW_ACTIONS.map((item) => ({ ...item })) }
function createScenarioRow() { return { id: `scenario-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userScenario: "", hardwareSense: "", driverTransmit: "", algorithmProcess: "", applicationTrigger: "", driverOutput: "", hardwareEffect: "" } }
function createScenarioRows(total) { return Array.from({ length: total }, () => createScenarioRow()) }
function createArchitectureImages() { return [1, 2, 3].map((index) => ({ key: `architecture-${index}`, url: "", name: "" })) }
function shuffle(items) {
const result = [...items]
for (let index = result.length - 1; index > 0; index -= 1) { const target = Math.floor(Math.random() * (index + 1));[result[index], result[target]] = [result[target], result[index]] }
return result
}
function normalizeOrder(ids) {
if (!Array.isArray(ids) || ids.length !== LAYERS.length || new Set(ids).size !== LAYERS.length) return null
const found = Object.fromEntries(LAYERS.map((item) => [item.id, item]))
return ids.every((id) => found[id]) ? ids.map((id) => ({ ...found[id] })) : null
}
function normalizeFlowOrder(ids) {
if (!Array.isArray(ids) || ids.length !== FLOW_ACTIONS.length || new Set(ids).size !== FLOW_ACTIONS.length) return null
const found = Object.fromEntries(FLOW_ACTIONS.map((item) => [item.id, item]))
return ids.every((id) => found[id]) ? ids.map((id) => ({ ...found[id] })) : null
}
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null } catch (_) { taskConfig.value = null } }
async function loadSavedProgress() {
try {
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data
const saved = parseSavedAnswer(answer?.step1Answer)
const stepTwoSaved = parseSavedAnswer(answer?.step2Answer)
const stepThreeSaved = parseSavedAnswer(answer?.step3Answer)
const stepFourSaved = parseSavedAnswer(answer?.step4Answer)
const order = normalizeOrder(saved?.layerIds)
if (order) layerOrder.value = order
stepOnePassed.value = Boolean(saved?.passed)
checkResult.value = saved?.checkResult || null
const flow = normalizeFlowOrder(stepTwoSaved?.actionIds)
if (flow) flowOrder.value = flow
stepTwoPassed.value = Boolean(stepTwoSaved?.passed)
stepTwoCheckResult.value = stepTwoSaved?.checkResult || null
const scenarios = normalizeScenarioRows(stepThreeSaved?.rows)
if (scenarios) scenarioRows.value = scenarios
stepThreePassed.value = Boolean(stepThreeSaved?.passed)
stepThreeCheckResult.value = stepThreeSaved?.checkResult || null
const images = normalizeArchitectureImages(stepFourSaved?.images)
if (images) architectureImages.value = images
stepFourPassed.value = Boolean(stepFourSaved?.passed)
stepFourCheckResult.value = stepFourSaved?.checkResult || null
const savedStep = Number(answer?.currentStep || saved?.currentStep)
if (savedStep > 1) currentStep.value = Math.min(savedStep, trainingSteps.value.length)
} catch (_) { /* Keep the randomized local state when no saved answer exists. */ }
}
function parseSavedAnswer(raw) { if (!raw) return null; try { return typeof raw === "string" ? JSON.parse(raw) : raw } catch (_) { return null } }
function normalizeScenarioRows(rows) {
if (!Array.isArray(rows) || rows.length < 3 || rows.length > 10) return null
return rows.map((row) => ({ id: row?.id || createScenarioRow().id, ...SCENARIO_FIELDS.reduce((result, field) => ({ ...result, [field]: String(row?.[field] || "") }), {}) }))
}
function normalizeArchitectureImages(images) {
if (!Array.isArray(images) || images.length !== 3) return null
return images.map((image, index) => ({ key: `architecture-${index + 1}`, url: String(image?.url || ""), name: String(image?.name || "") }))
}
function buildStepOneAnswer() { return { layerIds: layerOrder.value.map((item) => item.id), passed: stepOnePassed.value, checkResult: checkResult.value, updatedAt: new Date().toISOString() } }
function buildStepTwoAnswer() { return { actionIds: flowOrder.value.map((item) => item.id), passed: stepTwoPassed.value, checkResult: stepTwoCheckResult.value, updatedAt: new Date().toISOString() } }
function buildStepThreeAnswer() { return { rows: scenarioRows.value.map((row) => ({ ...row })), passed: stepThreePassed.value, checkResult: stepThreeCheckResult.value, updatedAt: new Date().toISOString() } }
function buildStepFourAnswer() { return { images: architectureImages.value.map((image) => ({ ...image })), passed: stepFourPassed.value, checkResult: stepFourCheckResult.value, updatedAt: new Date().toISOString() } }
function buildAnswer() { return { currentStep: currentStep.value, step1: buildStepOneAnswer(), step2: buildStepTwoAnswer(), step3: buildStepThreeAnswer(), step4: buildStepFourAnswer() } }
async function persist(saveAction = "SAVE") { await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: currentStep.value, step1Answer: JSON.stringify(buildStepOneAnswer()), step2Answer: JSON.stringify(buildStepTwoAnswer()), step3Answer: JSON.stringify(buildStepThreeAnswer()), step4Answer: JSON.stringify(buildStepFourAnswer()), submitted: stepFourPassed.value && saveAction === "SUBMIT" }) }
function onDragStart(id) { if (!checking.value) dragLayerId.value = id }
function onDrop(targetId) {
const fromIndex = layerOrder.value.findIndex((item) => item.id === dragLayerId.value); const toIndex = layerOrder.value.findIndex((item) => item.id === targetId)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
const next = [...layerOrder.value]; const [moving] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moving); layerOrder.value = next; checkResult.value = null; stepOnePassed.value = false
}
function moveLayer(index, direction) { const target = index + direction; if (target < 0 || target >= layerOrder.value.length) return; const next = [...layerOrder.value];[next[index], next[target]] = [next[target], next[index]]; layerOrder.value = next; checkResult.value = null; stepOnePassed.value = false }
function shuffleLayers() { layerOrder.value = shuffle(duplicateLayers()); checkResult.value = null; stepOnePassed.value = false }
function onFlowDragStart(id) { if (!checking.value) dragFlowId.value = id }
function onFlowDrop(targetId) {
const fromIndex = flowOrder.value.findIndex((item) => item.id === dragFlowId.value); const toIndex = flowOrder.value.findIndex((item) => item.id === targetId)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
const next = [...flowOrder.value]; const [moving] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moving); flowOrder.value = next; stepTwoCheckResult.value = null; stepTwoPassed.value = false
}
function moveFlow(index, direction) { const target = index + direction; if (target < 0 || target >= flowOrder.value.length) return; const next = [...flowOrder.value];[next[index], next[target]] = [next[target], next[index]]; flowOrder.value = next; stepTwoCheckResult.value = null; stepTwoPassed.value = false }
function shuffleFlow() { flowOrder.value = shuffle(duplicateFlowActions()); stepTwoCheckResult.value = null; stepTwoPassed.value = false }
function addScenarioRow() { if (scenarioRows.value.length >= 10) return; scenarioRows.value.push(createScenarioRow()); stepThreeCheckResult.value = null; stepThreePassed.value = false }
function removeScenarioRow() { if (scenarioRows.value.length <= 3) return; scenarioRows.value.pop(); stepThreeCheckResult.value = null; stepThreePassed.value = false }
async function checkAndSave() {
if (checking.value) return
checking.value = true
try {
const response = await checkProductArchitectureDesignStepOne(layerOrder.value.map((item) => item.id))
checkResult.value = response?.data || null; stepOnePassed.value = Boolean(checkResult.value?.allCorrect)
await persist("SAVE")
if (stepOnePassed.value) proxy?.$modal?.msgSuccess("排序正确,已保存并解锁下一步"); else proxy?.$modal?.msgWarning("排序尚未正确,请继续调整")
} catch (error) { proxy?.$modal?.msgError(error?.message || "判题失败,请稍后重试") } finally { checking.value = false }
}
async function checkAndSaveStepTwo() {
if (checking.value) return
checking.value = true
try {
const response = await checkProductArchitectureDesignStepTwo(flowOrder.value.map((item) => item.id))
stepTwoCheckResult.value = response?.data || null; stepTwoPassed.value = Boolean(stepTwoCheckResult.value?.allCorrect)
await persist("SAVE")
if (stepTwoPassed.value) proxy?.$modal?.msgSuccess("排序正确,已保存并解锁下一步"); else proxy?.$modal?.msgWarning("排序尚未正确,请继续调整")
} catch (error) { proxy?.$modal?.msgError(error?.message || "判题失败,请稍后重试") } finally { checking.value = false }
}
async function checkAndSaveStepThree() {
if (checking.value) return
const validationMessage = getScenarioValidationMessage(scenarioRows.value)
if (validationMessage) {
stepThreePassed.value = false
stepThreeCheckResult.value = { valid: false, message: validationMessage }
proxy?.$modal?.msgWarning(validationMessage)
return
}
checking.value = true
try {
const response = await checkProductArchitectureDesignStepThree(getRowsForScenarioValidation(scenarioRows.value).map((row) => ({ ...row })))
stepThreeCheckResult.value = response?.data || null; stepThreePassed.value = Boolean(stepThreeCheckResult.value?.valid)
await persist("SAVE")
if (stepThreePassed.value) proxy?.$modal?.msgSuccess("场景架构分析已保存并解锁下一步"); else proxy?.$modal?.msgWarning("请至少完整填写 3 个典型工作场景")
} catch (error) { proxy?.$modal?.msgError(error?.message || "校验失败,请稍后重试") } finally { checking.value = false }
}
async function uploadArchitectureImage(event, index) {
const file = event.target.files?.[0]
if (!file) return
if (!/^image\/(png|jpeg|webp|gif)$/i.test(file.type)) { proxy?.$modal?.msgError?.("请上传 PNG、JPG、JPEG、WEBP 或 GIF 格式的架构图"); event.target.value = ""; return }
uploadingArchitectureImage.value = index
try {
const formData = new FormData(); formData.append("file", file)
const response = await uploadStudentTrainingFile(formData)
const url = response?.url || response?.fileName || ""
if (!url) throw new Error("上传结果缺少文件地址")
architectureImages.value[index] = { ...architectureImages.value[index], url, name: response?.originalFilename || file.name }
stepFourPassed.value = false; stepFourCheckResult.value = null
await persist("SAVE")
proxy?.$modal?.msgSuccess(`架构图 ${index + 1} 上传成功`)
} catch (error) { proxy?.$modal?.msgError?.(error?.message || "架构图上传失败") } finally { uploadingArchitectureImage.value = null; event.target.value = "" }
}
function clearArchitectureImage(index) { architectureImages.value[index] = { ...architectureImages.value[index], url: "", name: "" }; stepFourPassed.value = false; stepFourCheckResult.value = null }
async function saveStepFour() { if (uploadingArchitectureImage.value !== null) return; await persist("SAVE"); proxy?.$modal?.msgSuccess("第 4 步进度已保存") }
async function checkAndSaveStepFour() {
if (checking.value || uploadingArchitectureImage.value !== null) return
checking.value = true
try {
const response = await checkProductArchitectureDesignStepFour(architectureImages.value.map((image) => image.url))
stepFourCheckResult.value = response?.data || null; stepFourPassed.value = Boolean(stepFourCheckResult.value?.valid)
await persist(stepFourPassed.value ? "SUBMIT" : "SAVE")
if (stepFourPassed.value) proxy?.$modal?.msgSuccess("产品架构设计实训已完成"); else proxy?.$modal?.msgWarning("请上传完整的三张核心功能架构图")
} catch (error) { proxy?.$modal?.msgError(error?.message || "校验失败,请稍后重试") } finally { checking.value = false }
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
async function goNextStep() {
if ((currentStep.value === 1 && !stepOnePassed.value) || (currentStep.value === 2 && !stepTwoPassed.value) || (currentStep.value === 3 && !stepThreePassed.value)) return
currentStep.value = Math.min(currentStep.value + 1, trainingSteps.value.length); await persist("SAVE")
}
function saveCurrentProgress() {
if (currentStep.value === 1) return checkAndSave()
if (currentStep.value === 2) return checkAndSaveStepTwo()
if (currentStep.value === 3) return checkAndSaveStepThree()
return saveStepFour()
}
function previousStep() { if (currentStep.value > 1) currentStep.value -= 1 }
async function nextStep() {
if (currentStep.value >= trainingSteps.value.length) return
const gates = {
1: [stepOnePassed, "请先完成第 1 步排序并判题通过"],
2: [stepTwoPassed, "请先完成第 2 步排序并判题通过"],
3: [stepThreePassed, "请先完成第 3 步场景架构分析并校验通过"],
}
const gate = gates[currentStep.value]
if (gate && !gate[0].value) { proxy?.$modal?.msgWarning(gate[1]); return }
await goNextStep()
}
function switchStep(step) {
if (step > 1 && !stepOnePassed.value) { proxy?.$modal?.msgWarning("请先完成第 1 步排序并判题通过"); return }
if (step > 2 && !stepTwoPassed.value) { proxy?.$modal?.msgWarning("请先完成第 2 步排序并判题通过"); return }
if (step > 3 && !stepThreePassed.value) { proxy?.$modal?.msgWarning("请先完成第 3 步场景架构分析并校验通过"); return }
currentStep.value = step
}
function exportOutcome() { const blob = new Blob([JSON.stringify(buildAnswer(), 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 lang="scss" scoped>
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.architecture-page {
/* 主色:教育蓝 */
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
/* 语义色 */
--student-danger: #ef4444;
--student-success: #2ba471;
/* 三层次标识色 */
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
/* 结构色 */
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
/* Element Plus 主色:教育蓝 */
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
.architecture-core {
display: grid;
gap: 12px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header ===== */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
line-height: 1.4;
font-weight: 700;
letter-spacing: -0.01em;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.65;
max-width: 100%;
}
.task-brief-text {
display: block;
}
.task-brief-toggle {
margin-left: 6px;
padding: 0;
border: 0;
color: var(--student-accent);
background: transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--student-accent-deep);
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e5e7eb;
b {
color: var(--student-text);
font-size: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 3px;
margin: 0;
padding-left: 18px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
}
.architecture-toolbar {
flex: 0 0 auto;
display: flex;
gap: 8px;
justify-content: flex-end;
max-width: 100%;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
.el-icon {
font-size: var(--student-fs-h2);
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
/* ===== workbench ===== */
.architecture-workbench {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 12px;
}
.architecture-step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 12px;
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.4;
}
.step-chip {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: var(--student-fs-caption);
font-weight: 600;
white-space: nowrap;
}
.section-eyebrow {
display: inline-flex;
align-items: center;
margin: 0;
padding: 0;
color: var(--student-accent);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.step-desc {
margin: 0 0 16px;
color: #4b5563;
font-size: var(--student-fs-body);
line-height: 1.7;
}
/* ===== 排序工作区 ===== */
.architecture-workspace {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 18px;
max-width: 950px;
margin: 0 auto;
}
.order-rail {
display: flex;
flex-direction: column;
align-items: center;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.order-rail i {
flex: 1;
width: 2px;
margin: 9px 0;
background: var(--student-panel-border);
position: relative;
}
.order-rail i::before,
.order-rail i::after {
content: "";
position: absolute;
left: -4px;
border: 5px solid transparent;
}
.order-rail i::before {
top: -1px;
border-bottom-color: var(--student-panel-border);
}
.order-rail i::after {
bottom: -1px;
border-top-color: var(--student-panel-border);
}
.layer-list {
display: grid;
gap: 14px;
}
.layer-card {
display: grid;
grid-template-columns: 32px 22px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 17px 18px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
cursor: grab;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.layer-card:hover,
.layer-card.dragging {
border-color: var(--student-accent);
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.1);
transform: translateY(-2px);
}
.layer-position {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 6px;
background: var(--student-accent-soft);
color: var(--student-accent);
font-weight: 700;
}
.drag-handle {
color: var(--student-text-secondary);
font-size: 20px;
letter-spacing: -5px;
transform: rotate(90deg);
}
.layer-copy h4 {
margin: 0 0 6px;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
.layer-copy p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.65;
}
.move-actions {
display: grid;
gap: 6px;
}
.move-actions button {
width: 32px;
min-height: 28px;
padding: 0;
border: 1px solid var(--student-panel-border);
border-radius: 7px;
background: #ffffff;
color: var(--student-text);
font: inherit;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== flow workspace ===== */
.flow-workspace {
max-width: 1000px;
margin: 0 auto;
}
.flow-caption {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 14px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.flow-caption i {
flex: 1;
height: 1px;
background: var(--student-panel-border);
}
.flow-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.flow-card {
display: grid;
grid-template-columns: 32px 22px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 17px 18px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
cursor: grab;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.flow-card:hover,
.flow-card.dragging {
border-color: var(--student-accent);
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.1);
transform: translateY(-2px);
}
/* ===== scene toolbar ===== */
.scene-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
color: var(--student-text);
font-weight: 700;
font-size: var(--student-fs-body);
}
.scene-toolbar em {
color: var(--student-text-secondary);
font-style: normal;
font-size: var(--student-fs-body-sm);
font-weight: 400;
}
.scene-toolbar__actions {
display: flex;
gap: 8px;
}
/* ===== 表格 ===== */
.scenario-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
}
.scenario-table {
width: 100%;
min-width: 1400px;
border-collapse: collapse;
table-layout: fixed;
}
.scenario-table th,
.scenario-table td {
border: 1px solid var(--student-panel-border);
}
.scenario-table th {
padding: 10px 8px;
color: var(--student-text);
background: #f9fafb;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: left;
line-height: 1.45;
}
.scenario-table th small {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 600;
}
.scenario-table td {
padding: 6px 8px;
vertical-align: top;
}
.scenario-table th:first-child,
.scenario-table td:first-child {
width: 52px;
}
.scenario-table th:nth-child(2),
.scenario-table td:nth-child(2) {
width: 170px;
}
.scenario-index {
color: var(--student-text-secondary);
font-weight: 700;
text-align: center;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.scenario-table :deep(.el-textarea__inner) {
min-height: 72px !important;
border: 0;
border-radius: 0;
color: var(--student-text);
background: transparent;
box-shadow: none !important;
padding: 6px 8px;
font-size: var(--student-fs-body);
line-height: 1.55;
resize: vertical;
}
.scenario-table :deep(.el-textarea__inner:focus) {
outline: none;
box-shadow: inset 0 0 0 2px var(--student-accent) !important;
}
.scenario-table :deep(.el-textarea__inner::placeholder) {
color: var(--student-text-secondary);
}
.scenario-table tr:last-child td {
border-bottom: 0;
}
.scenario-table th:last-child,
.scenario-table td:last-child {
border-right: 0;
}
/* ===== 架构图上传 ===== */
.architecture-image-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.architecture-image-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
.architecture-image-card__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 13px;
color: var(--student-text);
font-weight: 700;
}
.architecture-image-card__head small {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 500;
}
.architecture-image-trigger {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
min-height: 154px;
border: 1px dashed var(--student-accent);
border-radius: 10px;
color: var(--student-text);
background: var(--student-accent-soft);
cursor: pointer;
text-align: center;
transition: border-color 0.16s ease, background 0.16s ease;
&:hover {
border-color: var(--student-accent-deep);
background: #ffffff;
}
}
.architecture-image-trigger input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.architecture-image-trigger .el-icon {
font-size: 24px;
color: var(--student-accent);
}
.architecture-image-trigger span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.architecture-image-preview {
margin: 13px 0 0;
}
.architecture-image-preview :deep(.el-image) {
display: block;
width: 100%;
height: 170px;
overflow: hidden;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #fff;
cursor: zoom-in;
}
.architecture-image-preview figcaption {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-top: 9px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.architecture-image-preview figcaption span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.architecture-image-preview button {
border: 1px solid var(--student-accent);
border-radius: 6px;
padding: 4px 12px;
color: #ffffff;
background: var(--student-accent);
font: inherit;
font-size: var(--student-fs-body-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.16s ease;
}
.architecture-image-preview button {
border: 1px solid var(--student-panel-border);
background: #ffffff;
color: var(--student-text);
}
.architecture-image-preview button:hover {
border-color: var(--student-danger);
color: var(--student-danger);
}
/* ===== result ===== */
.result-message {
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid #f0c78a;
border-radius: 8px;
color: #92400e;
background: rgba(245, 124, 0, 0.08);
font-size: var(--student-fs-body);
}
.result-message.success {
border-color: #a7f3d0;
color: #166534;
background: rgba(43, 164, 113, 0.1);
}
/* ===== 按钮 ===== */
.outline-action,
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
.el-icon {
font-size: var(--student-fs-h2);
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
box-shadow: none;
}
}
.task-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
/* ===== placeholder ===== */
.placeholder-card {
min-height: 200px;
padding: 40px 20px;
text-align: center;
border: 1px dashed var(--student-panel-border);
border-radius: 10px;
background: var(--student-panel-bg);
}
.placeholder-card h3 {
margin: 8px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
/* ===== 响应式 ===== */
@media (max-width: 1000px) {
.architecture-image-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 840px) {
.flow-list {
grid-template-columns: 1fr;
}
.scene-toolbar {
align-items: flex-start;
flex-direction: column;
}
.scene-toolbar__actions {
width: 100%;
}
.scene-toolbar__actions .btn-nav {
flex: 1;
}
}
@media (max-width: 720px) {
.architecture-page {
padding: 12px;
}
.architecture-workspace {
grid-template-columns: 1fr;
}
.order-rail {
display: none;
}
.layer-card {
grid-template-columns: 30px 17px minmax(0, 1fr);
}
.move-actions {
grid-column: 3;
grid-template-columns: repeat(2, 32px);
justify-content: flex-end;
}
.flow-card {
grid-template-columns: 30px 17px minmax(0, 1fr);
}
.flow-card .move-actions {
grid-column: 3;
grid-template-columns: repeat(2, 32px);
}
.task-header {
flex-direction: column;
align-items: flex-start;
}
.architecture-toolbar {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
width: 100%;
}
.btn-nav {
width: 100%;
}
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
/* ===== 底部步骤导航按钮 ===== */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
</style>
<style lang="scss">
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝主题)
* ============================================================ */
#app .studentLayout section.app-main:has(.architecture-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector {
display: none !important;
}
.step-tab {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-bar {
display: none !important;
}
.step-no {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: var(--student-fs-body-sm) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no {
background: #2ba471 !important;
color: #ffffff !important;
}
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-no {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.architecture-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.architecture-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
</style>