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

4 months ago
<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>
4 months ago
</template>
4 months ago
<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 }
4 months ago
}
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;
}
}
4 months ago
</style>