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

1319 lines
53 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">
<span class="task-badge"><el-icon><Operation /></el-icon> 核心实训 · 产品规划与设计</span>
<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>
<div class="architecture-toolbar">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</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 class="step-action">{{ currentStep === index + 1 ? '当前步骤' : '点击切换' }} →</span></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 btn-submit" :disabled="checking" @click="checkAndSave">{{ checking ? '判题中…' : '检查排序并保存' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="shuffleLayers"><el-icon><RefreshLeft /></el-icon>重新打乱</button>
<button type="button" class="btn-nav btn-primary" :disabled="!stepOnePassed" @click="goNextStep">进入第 2 步<el-icon><ArrowRight /></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 btn-submit" :disabled="checking" @click="checkAndSaveStepTwo">{{ checking ? '判题中…' : '检查排序并保存' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="shuffleFlow"><el-icon><RefreshLeft /></el-icon>重新打乱</button>
<button type="button" class="btn-nav btn-primary" :disabled="!stepTwoPassed" @click="goNextStep">进入第 3 步<el-icon><ArrowRight /></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>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking" @click="checkAndSaveStepThree">{{ checking ? '校验中…' : '校验并保存' }}</button>
<button type="button" class="btn-nav btn-primary" :disabled="!stepThreePassed" @click="goNextStep">进入第 4 步<el-icon><ArrowRight /></el-icon></button>
</div>
</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 个核心功能绘制四层架构的逻辑调用关系图。每张图应从应用功能层向下标注算法、驱动及硬件单元,并以箭头标注信号流向。</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>
<section class="architecture-guide"><h4>参考工具及方案</h4><ol><li>下载模板,按应用功能层、算法处理层、驱动感知层、硬件物理层补全逻辑调用关系。</li><li>每张图围绕一个核心功能绘制,使用箭头清晰标注信号流向。</li></ol><button type="button" @click="downloadArchitectureTemplate">下载模板</button></section>
<p v-if="stepFourCheckResult" class="result-message" :class="{ success: stepFourCheckResult.valid }">{{ stepFourCheckResult.message }}</p>
<div class="task-actions"><button type="button" class="btn-nav" :disabled="uploadingArchitectureImage !== null || checking" @click="saveStepFour">保存进度</button><button type="button" class="btn-nav btn-submit" :disabled="uploadingArchitectureImage !== null || checking" @click="checkAndSaveStepFour">{{ checking ? '校验中…' : '校验并完成实训' }}</button></div>
</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>
</section>
</main>
<TrainingAiSidebar study-tip="先识别物理硬件、驱动感知、算法处理和应用功能各自职责,再从底层到顶层排序。" reference-title="分层判断提示" reference-text="· 硬件物理层:实体元器件与电路基础<br />· 驱动感知层:信号采集、转换与驱动<br />· 算法处理层:数据处理、识别与决策<br />· 应用功能层:用户可直接感知和操作的功能" :progress-items="progressItems" />
4 months ago
</div>
</template>
4 months ago
<script setup>
import { ArrowLeft, ArrowRight, 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 TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import { SCENARIO_FIELDS, getCompletedScenarioCount, getRowsForScenarioValidation, getScenarioValidationMessage } from "./productArchitectureScenarioValidation.mjs";
4 months ago
const { proxy } = getCurrentInstance();
const TASK_KEY = "product-structure";
const PRODUCT_ARCHITECTURE_TEMPLATE_URL = "/file/training-materials/product-architecture/13.产品架构图绘制模版.pptx";
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(false);
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));
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. */ }
4 months ago
}
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 downloadArchitectureTemplate() {
downloadBuiltInFile(PRODUCT_ARCHITECTURE_TEMPLATE_URL, "13.产品架构图绘制模版.pptx");
}
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 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: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
.architecture-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header ===== */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
.architecture-toolbar {
flex: 0 0 auto;
display: flex;
gap: 8px;
justify-content: flex-end;
max-width: 100%;
}
/* ===== workbench ===== */
.architecture-workbench {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.architecture-step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-chip {
border: 1px solid var(--student-panel-border);
border-radius: 6px;
padding: 3px 10px;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.section-eyebrow {
margin: 0;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.step-desc {
margin: 10px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 排序工作区 ===== */
.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: 11px;
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: 14px;
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(0, 0, 0, 0.08);
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: 15px;
font-weight: 700;
}
.layer-copy p {
margin: 0;
color: var(--student-text-secondary);
font-size: 13px;
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;
}
.move-actions button:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
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: 11px;
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: 14px;
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(0, 0, 0, 0.08);
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: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
line-height: 1.45;
}
.scenario-table th small {
color: var(--student-text-secondary);
font-size: 11px;
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;
padding: 6px 8px;
font-size: var(--student-fs-body);
line-height: 1.55;
resize: vertical;
}
.scenario-table :deep(.el-textarea__inner:focus) {
outline: 2px solid rgba(31, 41, 55, 0.2);
outline-offset: -2px;
}
.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: 11px;
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;
}
.architecture-image-trigger: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,
.architecture-guide 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);
}
.architecture-guide {
margin-top: 20px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
font-size: var(--student-fs-body);
line-height: 1.7;
}
.architecture-guide h4 {
margin: 0 0 8px;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.architecture-guide ol {
margin: 0 0 10px;
padding-left: 20px;
}
.architecture-guide li {
margin: 4px 0;
}
/* ===== result ===== */
.result-message {
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid #e8d5b0;
border-radius: 8px;
color: #8a4b08;
background: rgba(245, 124, 0, 0.08);
font-size: var(--student-fs-body);
}
.result-message.success {
border-color: #c2e0cf;
color: #2f6a4a;
background: rgba(74, 157, 110, 0.1);
}
/* ===== 按钮 ===== */
.outline-action,
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}
.outline-action:hover:not(:disabled),
.btn-nav:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.btn-submit,
.btn-primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.btn-submit:hover:not(:disabled),
.btn-primary:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
color: #ffffff;
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
.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; }
}
</style>
<style lang="scss">
/* ===== 画布 + 行高 ===== */
#app .studentLayout .app-main:has(.architecture-page) {
background: #ffffff !important;
}
#app .studentLayout .app-main:has(.architecture-page) .architecture-page {
line-height: 1.55 !important;
}
/* ===== 步骤导航:按钮切换风 ===== */
#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: 10px !important;
width: 100% !important;
margin: 4px 0 16px !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 {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action { display: none !important; }
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.architecture-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.architecture-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部工具栏按钮换肤 ===== */
.architecture-page .material-button.el-button,
.architecture-page .outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.architecture-page .material-button.el-button .el-icon,
.architecture-page .outline-action .el-icon {
font-size: 14px;
color: inherit;
}
.architecture-page .material-button.el-button:hover,
.architecture-page .material-button.el-button:focus,
.architecture-page .outline-action:hover:not(:disabled),
.architecture-page .outline-action:focus:not(:disabled) {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
.architecture-page .material-button.el-button.is-loading {
border-color: #d1d5db;
color: #374151;
background: #ffffff;
}
4 months ago
</style>