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/foundation/product-development-process...

1652 lines
55 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="product-process student-training-shell">
<main class="process-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>
</section>
<section class="process-workbench">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</div>
<nav v-if="trainingSteps.length" class="step-indicator" aria-label="产品开发主要流程步骤">
<button
v-for="(step, index) in trainingSteps"
:key="`${step}-${index}`"
type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
:aria-label="`切换至第 ${index + 1} 步:${step}`"
:aria-current="currentStep === index + 1 ? 'step' : undefined"
:title="step"
@click="currentStep = index + 1"
>
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-name">{{ step }}</span>
</button>
</nav>
<section class="process-step-card">
<template v-if="currentStep === 1">
<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="process-intro">互联网产品开发在拆解任务之前,团队必须先明确做什么样的产品、开发过程中有哪些卡点。这是后续排期的前提。请判断哪些开发环节需要特别关注:关键任务会影响全项目进度与最终落地效果,非关键任务可在核心任务约束下灵活协调。</p>
<div class="training-table-wrap">
<table class="process-table">
<thead><tr><th>阶段</th><th>任务编号</th><th>具体工作任务</th><th>是否关键任务</th></tr></thead>
<tbody>
<tr v-for="row in processRows" :key="row.taskNo" :class="feedbackClass(row.taskNo)">
<td class="stage-cell">{{ row.stage }}</td>
<td class="task-no-cell">{{ row.taskNo }}</td>
<td class="task-cell">{{ row.task }}</td>
<td class="select-cell">
<select v-model="row.classification" :disabled="checking" @change="clearFeedback(row.taskNo)">
<option value="" disabled>请选择</option>
<option v-for="option in CLASSIFICATION_OPTIONS" :key="option" :value="option">{{ option }}</option>
</select>
<p v-if="feedbackByTaskNo[row.taskNo]" class="classification-feedback">{{ feedbackByTaskNo[row.taskNo].message }}</p>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="checkResult" class="classification-summary" :class="{ 'is-success': checkResult.allCorrect }">本次判题:{{ checkResult.correctCount }} / {{ checkResult.totalCount }} 项分类正确</p>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking" @click="saveAndGoToStepTwo">{{ checking ? '判题中…' : '保存并进入第 2 步' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetTraining"><el-icon><RefreshLeft /></el-icon>清空填写</button>
</div>
</template>
<template v-else-if="currentStep === 2">
<div class="step-title-row">
<div><p class="section-eyebrow">Step 02</p><h3>{{ trainingSteps[1] }}</h3></div>
<div class="step-title-actions">
<button type="button" class="outline-action schedule-export-action" @click="exportScheduleList"><el-icon><Download /></el-icon>导出列表</button>
<span class="step-chip">项目排期练习</span>
</div>
</div>
<p class="process-intro">将开发一款互联网产品的模糊目标,拆解为可执行的具体工作任务。根据案例信息,填写参考工期和项目依赖关系。</p>
<p v-if="!hasCompletedStepOne" class="step-warning">请先完成第 1 步并判题通过;关键任务结果会自动带入本表。</p>
<div class="training-table-wrap">
<table class="process-table schedule-table">
<thead><tr><th>阶段</th><th>任务编号</th><th>具体工作任务</th><th>是否关键任务</th><th>开始时间</th><th>完成时间</th><th>项目依赖关系</th></tr></thead>
<tbody>
<tr v-for="row in processRows" :key="row.taskNo">
<td class="stage-cell">{{ row.stage }}</td>
<td class="task-no-cell">{{ row.taskNo }}</td>
<td class="task-cell">{{ row.task }}</td>
<td class="classification-cell">{{ row.classification || '—' }}</td>
<td><input v-model="row.startDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model="row.endDate" type="date" :disabled="!hasCompletedStepOne || checking" /></td>
<td><input v-model.trim="row.dependency" class="dependency-input" :disabled="!hasCompletedStepOne || checking" placeholder="示例:开始-开始(SS)" maxlength="100" /></td>
</tr>
</tbody>
</table>
</div>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking || !hasCompletedStepOne" @click="saveAndGoToStepThree">{{ checking ? '校验中…' : '保存并进入第 3 步' }}</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetStepTwo"><el-icon><RefreshLeft /></el-icon>清空本步</button>
</div>
</template>
<template v-else-if="currentStep === 3">
<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="process-intro">根据上表数据,使用工具绘制甘特图,并上传。</p>
<p v-if="!stepTwoPassed" class="step-warning">请先保存第 2 步项目排期,再上传甘特图。</p>
<section class="gantt-upload-panel">
<label class="gantt-upload-trigger" :class="{ disabled: !stepTwoPassed || uploadingGantt }">
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" :disabled="!stepTwoPassed || uploadingGantt" @change="uploadGanttImage" />
<el-icon><Upload /></el-icon>
<strong>{{ uploadingGantt ? '上传中…' : ganttImage.url ? '替换甘特图' : '上传甘特图' }}</strong>
<span>支持 PNG、JPG、JPEG、WEBP、GIF</span>
</label>
<figure v-if="ganttImage.url" class="gantt-preview">
<img :src="resolveGanttImageUrl(ganttImage.url)" alt="已上传的甘特图" @click="previewGanttImage" />
<figcaption>{{ ganttImage.name }}</figcaption>
</figure>
</section>
<section class="gantt-guide">
<h4>推荐工具如下,自行选择作图工具:</h4>
<ol>
<li>Excel/WPS表格,利用堆积条形图制作,可参考《实训操作说明》完成。<button type="button" @click="downloadPracticeGuide">下载实训操作说明</button></li>
<li>下载Excel模板,填写关键信息,完成甘特图绘制。<button type="button" @click="downloadGanttTemplate">下载甘特图绘制模板</button></li>
<li>导出步骤二信息表,上传到通用AI绘图模型,通过大模型绘制甘特图。参考提示词:请根据产品WBS任务拆解与排期表生成甘特图用于后续项目管理。</li>
</ol>
</section>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="uploadingGantt || !stepTwoPassed" @click="saveAndGoToStepFour">保存并进入第 4 步</button>
<button type="button" class="btn-nav" :disabled="uploadingGantt" @click="resetStepThree"><el-icon><RefreshLeft /></el-icon>清空本步</button>
</div>
</template>
<template v-else-if="currentStep === 4">
<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 v-if="!stepThreePassed" class="step-warning">请先上传并保存第 3 步甘特图,再完成本步复盘。</p>
<section class="process-reflection" :class="{ disabled: !stepThreePassed }">
<label class="reflection-field">
<span>(1)观察甘特图,思考电商运营在产品开发过程中,可以提前介入同步完成哪些工作,以缩短运营周期、提高效率。(如“预热蓄水”“内容种草”等)</span>
<textarea v-model.trim="stepFourForm.parallelWorkAnalysis" :disabled="!stepThreePassed || checking" placeholder="请结合甘特图说明可同步推进的电商运营工作、介入节点及预期效率提升" />
</label>
<div class="reflection-question">(2)在互联网产品开发管理中,你认为哪些环节可以借助AI工具提升效率?请列举至少2个具体应用场景。</div>
<div class="scenario-grid">
<label class="reflection-field"><span>场景1:</span><textarea v-model.trim="stepFourForm.aiScenarioOne" :disabled="!stepThreePassed || checking" placeholder="例如:使用 AI 汇总竞品评价与用户需求,辅助生成PRD初稿" /></label>
<label class="reflection-field"><span>场景2:</span><textarea v-model.trim="stepFourForm.aiScenarioTwo" :disabled="!stepThreePassed || checking" placeholder="例如:使用 AI 生成电商详情页文案、主图脚本或运营素材" /></label>
</div>
</section>
<div class="task-actions">
<button type="button" class="btn-nav btn-submit" :disabled="checking || !stepThreePassed" @click="submitTraining">提交任务</button>
<button type="button" class="btn-nav" :disabled="checking" @click="resetStepFour"><el-icon><RefreshLeft /></el-icon>清空本步</button>
</div>
</template>
<div v-else class="step-placeholder"><p>第 {{ currentStep }} 步内容待配置</p><span>第 1 步答案已保存,可从上方步骤栏返回查看。</span></div>
</section>
</section>
</main>
<TrainingAiSidebar
study-tip="请根据产品开发流程中任务对总进度和最终落地效果的影响,判断其是否为关键任务。"
reference-title="关键任务判断提示"
reference-text="· 关键任务:直接影响产品定义、核心技术验证、量产交付或上市节奏<br />· 非关键任务:可在核心任务约束下进行资源协调,不直接阻塞整体排期"
:progress-items="progressItems"
/>
</div>
</template>
<script setup>
import { Download, Operation, RefreshLeft, Upload } from "@element-plus/icons-vue";
import * as XLSX from "xlsx";
import { checkProductDevelopmentProcessStepOne, checkProductDevelopmentProcessStepTwo, checkProductDevelopmentProcessStepThree, checkProductDevelopmentProcessStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isExternal } from "@/utils/validate";
import useUserStore from "@/store/modules/user";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const TASK_KEY = "product-development-process";
const GANTT_PRACTICE_GUIDE_URL = "/file/training-materials/product-development-process/3.甘特图制作实训操作说明.docx";
const GANTT_PRACTICE_GUIDE_NAME = "3.甘特图制作实训操作说明.docx";
const GANTT_TEMPLATE_URL = "/file/training-materials/product-development-process/3.项目进度管理表-项目进度甘特图模版.xlsx";
const GANTT_TEMPLATE_NAME = "3.项目进度管理表-项目进度甘特图模版.xlsx";
const CLASSIFICATION_OPTIONS = ["关键任务", "非关键任务"];
const DEFAULT_STEPS = ["产品开发关键任务识别", "任务依赖关系分析", "开发排期制定", "流程复盘优化"];
const PROCESS_TASKS = [
["需求锚定", "1.1", "竞品调研与用户需求分析"], ["", "1.2", "产品概念确认与核心卖点提炼"],
["产品定义", "2.1", "产品需求文档(PRD)撰写与评审"], ["", "2.2", "外观ID设计"], ["", "2.3", "功能规格确认"],
["开发验证", "3.1", "硬件方案选型与原理图设计"], ["", "3.2", "软件架构及软硬件协同设计"], ["", "3.3", "结构设计评审"], ["", "3.4", "功能手板制作与外观评审"], ["", "3.5", "工程验证测试与设计验证测试"], ["", "3.6", "PVT小批量试产"],
["供应链对接", "4.1", "供应商评估与选定"], ["", "4.2", "模具正式开模与试模修模"], ["", "4.3", "产品认证及质量检测"], ["", "4.4", "BOM成本核算与采购合同签订"],
["上市推广", "5.1", "产品量产及入库"], ["", "5.2", "电商详情页全渠道上线"], ["", "5.3", "正式开售与首销爆发"], ["", "5.4", "首批用户评价监控与差评预警"],
];
const defaultTask = {
title: "产品开发主要流程",
background: "产品开发需要识别影响整体交付、量产和上市节奏的关键环节,并据此安排资源与排期。",
goals: ["理解产品开发的主要阶段与任务", "识别影响项目整体进度的关键任务", "形成后续排期和资源协调的判断依据"],
requirement: "请按任务对整体开发进度和最终落地效果的影响,判断其为关键任务或非关键任务。",
};
const taskConfig = ref(null);
const processRows = ref(createProcessRows());
const currentStep = ref(1);
const briefOpen = ref(false);
const checking = ref(false);
const draftReady = ref(false);
const checkResult = ref(null);
const stepOnePassed = ref(false);
const stepTwoPassed = ref(false);
const stepThreePassed = ref(false);
const ganttImage = ref({ url: "", name: "" });
const uploadingGantt = ref(false);
const stepFourForm = ref({ parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" });
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title);
const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background);
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals));
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement);
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS));
const feedbackByTaskNo = computed(() => Object.fromEntries((checkResult.value?.items || []).map((item) => [item.taskNo, item])));
const hasCompletedStepOne = computed(() => stepOnePassed.value);
const progressItems = computed(() => [
{ text: `进行中:${trainingSteps.value[Math.max(0, currentStep.value - 1)] || taskTitle.value}`, status: "doing" },
{ text: "完成本步后进入第 2 步", status: "" },
{ text: "可选:导出实训成果", status: "" },
]);
onMounted(async () => {
await loadTaskConfig();
loadDraft();
await loadSavedAnswer();
draftReady.value = true;
});
watch(() => JSON.stringify(buildDraft()), persistDraft);
function createProcessRows() {
return PROCESS_TASKS.map(([stage, taskNo, task]) => ({ stage, taskNo, task, classification: "", startDate: "", endDate: "", dependency: "" }));
}
function parseArray(value, fallback) {
if (Array.isArray(value)) return value.length ? value : fallback;
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback;
} catch (error) {
const list = String(value).split(/[;;|]/).map((item) => item.trim()).filter(Boolean);
return list.length ? list : fallback;
}
}
async function loadTaskConfig() {
try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; }
}
async function loadSavedAnswer() {
try {
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data;
if (answer?.step1Answer) {
restoreOutcome(answer.step1Answer);
stepOnePassed.value = processRows.value.every((row) => CLASSIFICATION_OPTIONS.includes(row.classification));
clearDraft();
}
if (answer?.step2Answer) restoreStepTwoOutcome(answer.step2Answer);
if (answer?.step2Answer) stepTwoPassed.value = true;
if (answer?.step3Answer) {
restoreStepThreeOutcome(answer.step3Answer);
stepThreePassed.value = true;
}
if (answer?.step4Answer) restoreStepFourOutcome(answer.step4Answer);
const savedStep = Number(answer?.currentStep);
if (Number.isInteger(savedStep) && savedStep > 0) currentStep.value = savedStep;
} catch (error) {
// Keep the browser draft available while the answer endpoint is unavailable.
}
}
function restoreOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const savedRows = Array.isArray(parsed?.items) ? parsed.items : [];
const byTaskNo = Object.fromEntries(savedRows.map((item) => [item.taskNo, item]));
processRows.value = createProcessRows().map((row) => ({ ...row, classification: byTaskNo[row.taskNo]?.classification || "" }));
} catch (error) {
processRows.value = createProcessRows();
}
}
function restoreStepTwoOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const byTaskNo = Object.fromEntries((Array.isArray(parsed?.items) ? parsed.items : []).map((item) => [item.taskNo, item]));
processRows.value = processRows.value.map((row) => ({
...row,
startDate: byTaskNo[row.taskNo]?.startDate || "",
endDate: byTaskNo[row.taskNo]?.endDate || "",
dependency: byTaskNo[row.taskNo]?.dependency || "",
}));
} catch (error) {
// Ignore a malformed historical second-step answer.
}
}
function restoreStepThreeOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
ganttImage.value = { url: parsed?.ganttImageUrl || "", name: parsed?.ganttImageName || "" };
} catch (error) {
ganttImage.value = { url: "", name: "" };
}
}
function restoreStepFourOutcome(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
stepFourForm.value = {
parallelWorkAnalysis: parsed?.parallelWorkAnalysis || "",
aiScenarioOne: parsed?.aiScenarioOne || "",
aiScenarioTwo: parsed?.aiScenarioTwo || "",
};
} catch (error) {
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
}
}
function getLocalDraftKey() {
const info = userStore.userInfo || {};
return `${TASK_KEY}:draft:${info.userId || info.user_id || info.username || info.userName || "anonymous"}`;
}
function buildOutcome() {
return { title: taskTitle.value, items: processRows.value.map(({ taskNo, classification }) => ({ taskNo, classification })) };
}
function buildStepTwoOutcome() {
return {
title: taskTitle.value,
items: processRows.value.map(({ taskNo, classification, startDate, endDate, dependency }) => ({ taskNo, classification, startDate, endDate, dependency })),
};
}
function getStepTwoValidationMessage() {
for (const row of processRows.value) {
if (!String(row.dependency || "").trim()) return `请填写任务 ${row.taskNo} 的项目依赖关系`;
if (!String(row.startDate || "").trim()) return `请填写任务 ${row.taskNo} 的开始时间`;
if (!String(row.endDate || "").trim()) return `请填写任务 ${row.taskNo} 的完成时间`;
if (row.startDate > row.endDate) return `任务 ${row.taskNo} 的完成时间不能早于开始时间`;
}
return "";
}
function buildStepThreeOutcome() {
return { title: taskTitle.value, ganttImageUrl: ganttImage.value.url, ganttImageName: ganttImage.value.name };
}
function buildStepFourOutcome() {
return { ...stepFourForm.value };
}
function buildDraft() { return { step1: buildOutcome(), step2: buildStepTwoOutcome(), step3: buildStepThreeOutcome(), step4: buildStepFourOutcome(), currentStep: currentStep.value }; }
function persistDraft() {
if (!draftReady.value) return;
try { localStorage.setItem(getLocalDraftKey(), JSON.stringify(buildDraft())); } catch (error) { /* Browser draft is optional. */ }
}
function loadDraft() {
try {
const raw = localStorage.getItem(getLocalDraftKey());
if (raw) {
const draft = JSON.parse(raw);
restoreOutcome(draft?.step1 || draft);
if (draft?.step2) restoreStepTwoOutcome(draft.step2);
if (draft?.step3) restoreStepThreeOutcome(draft.step3);
if (draft?.step4) restoreStepFourOutcome(draft.step4);
if (Number(draft?.currentStep) > 0) currentStep.value = Number(draft.currentStep);
}
} catch (error) { clearDraft(); }
}
function clearDraft() {
try { localStorage.removeItem(getLocalDraftKey()); } catch (error) { /* Ignore storage failures. */ }
}
function feedbackClass(taskNo) {
const feedback = feedbackByTaskNo.value[taskNo];
return feedback ? (feedback.correct ? "is-correct" : "is-incorrect") : "";
}
function clearFeedback(taskNo) {
if (!feedbackByTaskNo.value[taskNo]) return;
checkResult.value = { ...checkResult.value, items: checkResult.value.items.filter((item) => item.taskNo !== taskNo) };
}
async function saveAndGoToStepTwo() {
if (checking.value) return;
checking.value = true;
try {
const response = await checkProductDevelopmentProcessStepOne(buildOutcome().items);
checkResult.value = response?.data || null;
if (!checkResult.value?.allCorrect) {
proxy?.$modal?.msgError?.("存在分类错误,请根据提示修改后再进入第 2 步");
return;
}
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildOutcome()), currentStep: 2, submitted: false, saveAction: "SAVE" });
clearDraft();
stepOnePassed.value = true;
currentStep.value = 2;
proxy?.$modal?.msgSuccess("已保存,已进入第 2 步");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请完成全部任务分类后再判题");
} finally {
checking.value = false;
}
}
async function resetTraining() {
processRows.value = createProcessRows();
currentStep.value = 1;
checkResult.value = null;
stepOnePassed.value = false;
stepTwoPassed.value = false;
stepThreePassed.value = false;
ganttImage.value = { url: "", name: "" };
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
clearDraft();
try {
checking.value = true;
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildOutcome()), currentStep: 1, submitted: false, saveAction: "RESET" });
} catch (error) {
// The local reset remains effective if the backend is temporarily unavailable.
} finally {
checking.value = false;
}
proxy?.$modal?.msgSuccess("已清空填写内容");
}
async function saveAndGoToStepThree() {
if (checking.value || !hasCompletedStepOne.value) return;
const validationMessage = getStepTwoValidationMessage();
if (validationMessage) {
proxy?.$modal?.msgWarning?.(validationMessage);
return;
}
checking.value = true;
try {
await checkProductDevelopmentProcessStepTwo(buildStepTwoOutcome().items);
await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwoOutcome()), currentStep: 3, submitted: false, saveAction: "SAVE" });
clearDraft();
stepTwoPassed.value = true;
currentStep.value = 3;
proxy?.$modal?.msgSuccess("已保存,已进入第 3 步");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请完整填写任务工期和项目依赖关系");
} finally {
checking.value = false;
}
}
function resetStepTwo() {
processRows.value = processRows.value.map((row) => ({ ...row, startDate: "", endDate: "", dependency: "" }));
proxy?.$modal?.msgSuccess("已清空第 2 步填写内容");
}
async function uploadGanttImage(event) {
const file = event.target.files?.[0];
if (!file || !stepTwoPassed.value) return;
if (!/^image\/(png|jpeg|webp|gif)$/i.test(file.type)) {
proxy?.$modal?.msgError?.("请上传 PNG、JPG、JPEG、WEBP 或 GIF 格式的甘特图");
event.target.value = "";
return;
}
uploadingGantt.value = true;
try {
const formData = new FormData();
formData.append("file", file);
const response = await uploadStudentTrainingFile(formData);
ganttImage.value = {
url: response?.url || response?.fileName || "",
name: response?.originalFilename || file.name,
};
if (!ganttImage.value.url) throw new Error("上传结果缺少文件地址");
proxy?.$modal?.msgSuccess("甘特图上传成功");
} catch (error) {
ganttImage.value = { url: "", name: "" };
proxy?.$modal?.msgError?.(error?.message || "甘特图上传失败");
} finally {
uploadingGantt.value = false;
event.target.value = "";
}
}
async function saveAndGoToStepFour() {
if (uploadingGantt.value || !stepTwoPassed.value) return;
checking.value = true;
try {
await checkProductDevelopmentProcessStepThree(buildStepThreeOutcome());
await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThreeOutcome()), currentStep: 4, submitted: false, saveAction: "SAVE" });
clearDraft();
stepThreePassed.value = true;
currentStep.value = 4;
proxy?.$modal?.msgSuccess("已保存,已进入第 4 步");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请先上传甘特图");
} finally {
checking.value = false;
}
}
function resetStepThree() {
ganttImage.value = { url: "", name: "" };
proxy?.$modal?.msgSuccess("已清空第 3 步上传内容");
}
async function submitTraining() {
if (checking.value || !stepThreePassed.value) return;
checking.value = true;
try {
await checkProductDevelopmentProcessStepFour(buildStepFourOutcome());
await saveStudentTrainingAnswer(TASK_KEY, { step4Answer: JSON.stringify(buildStepFourOutcome()), currentStep: 4, submitted: true, saveAction: "SUBMIT" });
clearDraft();
proxy?.$modal?.msgSuccess("任务提交成功");
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "请完成并行协同复盘与两个AI应用场景");
} finally {
checking.value = false;
}
}
function resetStepFour() {
stepFourForm.value = { parallelWorkAnalysis: "", aiScenarioOne: "", aiScenarioTwo: "" };
proxy?.$modal?.msgSuccess("已清空第 4 步填写内容");
}
function resolveGanttImageUrl(url) {
const source = String(url || "").trim();
if (!source || source.startsWith("data:") || source.startsWith("blob:")) return source;
if (!isExternal(source)) return source.startsWith("/") ? source : `/${source}`;
try {
const parsed = new URL(source);
if (["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
return source;
}
return source;
}
function previewGanttImage() {
window.open(resolveGanttImageUrl(ganttImage.value.url), "_blank", "noopener,noreferrer");
}
function downloadPracticeGuide() {
downloadBuiltInFile(GANTT_PRACTICE_GUIDE_URL, GANTT_PRACTICE_GUIDE_NAME);
}
function downloadGanttTemplate() {
downloadBuiltInFile(GANTT_TEMPLATE_URL, GANTT_TEMPLATE_NAME);
}
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);
}
function exportScheduleList() {
const rows = processRows.value.map((row) => ({
阶段: row.stage,
任务编号: row.taskNo,
具体工作任务: row.task,
是否关键任务: row.classification,
开始时间: row.startDate,
完成时间: row.endDate,
项目依赖关系: row.dependency,
}));
const worksheet = XLSX.utils.json_to_sheet(rows);
worksheet["!cols"] = [
{ wch: 12 }, { wch: 10 }, { wch: 28 }, { wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 22 },
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "WBS排期表");
XLSX.writeFile(workbook, "产品开发WBS任务拆解与排期表.xlsx");
proxy?.$modal?.msgSuccess("列表导出成功");
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildDraft(), null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "产品开发主要流程-实训成果.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess("导出成功");
}
</script>
<style lang="scss" scoped>
/*
* 页面级令牌(墨黑极简主题):纯白画布 + 墨黑主操作 + 中性灰层次。
* 覆盖全局主色/文字/边框令牌,使所有 var(--student-*) 规则自动重着色。
*/
.product-process {
/* 主色:墨黑 */
--student-accent: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
/* 点缀色:暖橙(小面积使用) */
--student-terracotta: #e8953c;
/* 语义色 */
--student-danger: #d9554c;
--student-success: #4a9d6e;
/* 结构色:中性灰白系 */
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
/* 紧凑字号梯度:整体下调一档(11→17px) */
--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);
}
/* 主列布局:容器栅格与内边距由全局 .student-training-shell 统一提供 */
.process-core {
display: grid;
gap: 10px;
align-content: start;
}
/* 任务头:徽章 + 标题 + 可折叠简介 */
.task-header {
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;
}
.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: 17px;
line-height: 1.35;
font-weight: 700;
}
.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;
b {
color: var(--student-text);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* 工作台:白底卡片 */
.process-workbench {
margin: 0;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
&::before {
display: none !important;
content: none !important;
}
}
/* 仅含操作按钮的头部:右对齐,无分隔线 */
.workbench-head--actions-only {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
padding-bottom: 0;
border-bottom: 0;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* 顶部"导出实训成果"按钮:白底灰描边次按钮 */
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
font-family: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
&:hover {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
}
/* 案例资料按钮(TrainingMaterialButton 子组件)的换肤见非 scoped 块 */
/* 步骤导航(极简文字下划线)的换肤见非 scoped 块 */
.step-name {
display: block;
overflow: hidden;
min-width: 0;
color: inherit;
text-overflow: ellipsis;
}
/* 步骤卡片:透明嵌入工作台 */
.process-step-card {
margin: 0;
background: transparent;
}
.step-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.step-title-row h3 {
margin: 2px 0 0;
color: var(--student-text);
font-size: 15px;
font-weight: 700;
line-height: 1.35;
}
.step-title-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* 标题行内联导出:次按钮的紧凑小号(继承 .outline-action 描边与圆角语言) */
.schedule-export-action {
min-height: 32px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
}
/* Step 编号小标签:灰色宽字距引导文字 */
.section-eyebrow {
margin: 0;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 800;
line-height: 1.3;
letter-spacing: 0.12em;
}
/* 步骤小徽章:中性灰白 */
.step-chip {
padding: 3px 10px;
border: 1px solid #e5e7eb;
border-radius: 999px;
color: #6b7280;
background: #ffffff;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
/* 步骤说明 */
.process-intro {
margin: 0 0 12px;
color: #4b5563;
font-size: 13px;
line-height: 1.6;
}
/* 表格:纯白 + 中性边框 */
.training-table-wrap {
overflow-x: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
}
.process-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
table-layout: fixed;
}
.process-table th {
padding: 9px 10px;
border-bottom: 1px solid #e5e7eb;
color: #374151;
background: #ffffff;
font-size: 12px;
font-weight: 700;
text-align: center;
}
.process-table td {
padding: 9px 10px;
border-top: 1px solid #f0f1f3;
color: #374151;
background: #ffffff;
vertical-align: middle;
}
.stage-cell,
.task-no-cell {
color: #1f2937;
font-size: 12px;
font-weight: 700;
text-align: center;
}
.task-cell {
color: #374151;
font-size: 12px;
line-height: 1.55;
text-align: center;
}
/* 下拉与日期输入:中性描边,聚焦墨黑 */
.select-cell select,
.schedule-table input {
box-sizing: border-box;
width: 100%;
min-height: 34px;
padding: 5px 9px;
border: 1px solid #d1d5db;
border-radius: 8px;
outline: none;
color: #1f2937;
background: #ffffff;
font-family: inherit;
font-size: 12px;
}
.select-cell select:focus,
.schedule-table input:focus {
border-color: #1f2937;
box-shadow: 0 0 0 3px rgba(31, 41, 55, 0.1);
}
/* 判题反馈:仅文字着色,不铺行底色 */
.classification-feedback {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.4;
}
tr.is-correct .classification-feedback {
color: var(--student-success);
}
tr.is-incorrect .classification-feedback {
color: var(--student-danger);
}
/* 判题汇总横幅 */
.classification-summary {
margin: 12px 0 0;
padding: 8px 12px;
border-radius: 8px;
color: var(--student-danger);
background: rgba(217, 85, 76, 0.08);
font-size: 12px;
font-weight: 600;
}
.classification-summary.is-success {
color: var(--student-success);
background: rgba(74, 157, 110, 0.08);
}
/* 步骤操作按钮:居中紧凑排列(与标准页 .step-navigation 同规格) */
.task-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
/*
* 次按钮(清空填写/本步):白底 + 墨黑半透明描边 + 墨黑字;
* 与标准样例一致:40px 高、10px 圆角、700 字重、hover 轻微浮起。
*/
.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-accent);
background: #ffffff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-shadow: none;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.btn-nav:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
color: var(--student-accent);
background: var(--student-accent-soft);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.btn-nav:active:not(:disabled) {
transform: translateY(0);
}
/* 主按钮(保存进入下一步 / 提交任务):墨黑实底白字 + 中性柔影 */
.btn-nav.btn-submit {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.btn-nav.btn-submit:hover:not(:disabled) {
border-color: var(--student-accent-deep);
color: #ffffff;
background: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.32);
}
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
/* 空步骤占位 */
.step-placeholder {
display: grid;
place-items: center;
min-height: 220px;
padding: 24px;
border: 1px dashed #d1d5db;
border-radius: 12px;
color: #9ca3af;
background: #fafbfc;
text-align: center;
}
.step-placeholder p {
margin: 0 0 6px;
color: #4b5563;
font-size: 14px;
font-weight: 600;
}
/* 步骤门控提示:中性灰底,安静提示 */
.step-warning {
margin: 0 0 12px;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-left: 3px solid #9ca3af;
border-radius: 8px;
color: #6b7280;
background: #f9fafb;
font-size: 12px;
}
/* 排期表列宽 */
.schedule-table { min-width: 1020px; }
.schedule-table .stage-cell { width: 7%; }
.schedule-table .task-no-cell { width: 7%; }
.schedule-table .task-cell { width: 18%; }
.classification-cell {
width: 12%;
color: #1f2937;
font-size: 12px;
font-weight: 700;
text-align: center;
}
.schedule-table th:nth-child(5),
.schedule-table td:nth-child(5),
.schedule-table th:nth-child(6),
.schedule-table td:nth-child(6) { width: 18%; }
.schedule-table .dependency-input { min-width: 0; }
.schedule-table .dependency-input::placeholder {
color: #9ca3af;
opacity: 1;
}
.schedule-table th:nth-child(7),
.schedule-table td:nth-child(7) { width: 20%; }
.schedule-table input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 甘特图上传区:白底 + 中性虚线,无光晕 */
.gantt-upload-panel {
display: grid;
justify-items: center;
gap: 16px;
min-height: 260px;
padding: 24px 16px;
border: 1px dashed #d1d5db;
border-radius: 12px;
background: #ffffff;
}
/* 上传触发器:墨黑实底主操作 */
.gantt-upload-trigger {
display: grid;
place-items: center;
align-content: center;
gap: 8px;
width: 240px;
height: 110px;
border-radius: 10px;
color: #ffffff;
background: #1f2937;
cursor: pointer;
transition: background 0.18s ease;
}
.gantt-upload-trigger:hover:not(.disabled) {
background: #111827;
}
.gantt-upload-trigger input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.gantt-upload-trigger .el-icon {
font-size: 22px;
color: #ffffff;
}
.gantt-upload-trigger span {
color: #ffffff;
font-size: 12px;
}
.gantt-upload-trigger.disabled {
cursor: not-allowed;
background: #9ca3af;
}
.gantt-preview {
width: min(100%, 720px);
margin: 0;
text-align: center;
}
.gantt-preview img {
display: block;
width: 100%;
max-height: 380px;
object-fit: contain;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: zoom-in;
}
.gantt-preview figcaption {
margin-top: 6px;
color: #9ca3af;
font-size: 11px;
}
/* 练习指引:白底中性左色条 */
.gantt-guide {
width: 100%;
box-sizing: border-box;
margin-top: 16px;
padding: 14px 18px;
border: 1px solid #e5e7eb;
border-left: 3px solid #1f2937;
border-radius: 8px;
color: #4b5563;
background: #ffffff;
}
.gantt-guide h4 {
margin: 0 0 10px;
color: #1f2937;
font-size: 13px;
font-weight: 700;
}
.gantt-guide ol {
margin: 0;
padding-left: 18px;
}
.gantt-guide li {
margin: 6px 0;
font-size: 12px;
line-height: 1.6;
}
.gantt-guide button {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 10px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 6px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
}
.gantt-guide button:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
/* 复盘区 */
.process-reflection {
display: grid;
gap: 18px;
}
.reflection-field {
display: grid;
gap: 8px;
color: #4b5563;
font-size: 13px;
line-height: 1.55;
}
.reflection-field span {
color: #1f2937;
font-weight: 600;
}
.reflection-field textarea {
width: 100%;
box-sizing: border-box;
min-height: 150px;
resize: vertical;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #1f2937;
background: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
}
.reflection-field textarea:focus {
outline: 0;
border-color: #1f2937;
box-shadow: 0 0 0 3px rgba(31, 41, 55, 0.1);
}
.reflection-field textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
background: #f9fafb;
}
.reflection-question {
color: #1f2937;
font-size: 13px;
font-weight: 600;
line-height: 1.6;
}
/* 两个场景并排 */
.scenario-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.scenario-grid .reflection-field textarea {
min-height: 150px;
}
.process-reflection.disabled {
opacity: 0.7;
}
@media (max-width: 1100px) {
.product-process {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.product-process {
padding: 12px;
}
.process-core {
padding: 14px;
border-radius: 16px;
}
.step-title-row {
flex-direction: column;
align-items: stretch;
}
.step-indicator {
width: 100%;
overflow-x: auto;
}
.step-tab {
flex: 0 0 auto;
}
.step-chip {
display: none;
}
.scenario-grid {
grid-template-columns: 1fr;
}
/* 按钮区窄屏全宽的强制覆盖见非 scoped 块 */
}
</style>
<style lang="scss">
/* ===== AI 侧栏样式已收敛进 TrainingAiSidebar 组件(墨黑极简),此处不再覆盖 ===== */
/* ============================================================
* 步骤导航重设计:按钮切换风(分段按钮 + 墨黑激活)
* id 锚点 + !important 覆盖全局浅蓝胶囊与杂志风旧规则。
* ============================================================ */
#app .studentLayout section.app-main:has(.product-process) .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 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-tab {
flex: 1 1 0 !important;
max-width: 220px !important;
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: 42px !important;
margin: 0 !important;
padding: 8px 12px !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: normal !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !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: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
&::after { display: none !important; }
}
.step-name { color: inherit !important; }
/* 未到达项 hover:黑边浅底,编号徽章转墨黑 */
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-no { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成:绿色编号徽章 */
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-no { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-no { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤:墨黑实底白字,白色编号徽章黑字 */
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-no { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.product-process) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-indicator .step-tab {
flex: 0 0 148px !important;
max-width: none !important;
}
}
/* ============================================================
* 按钮风格强制同步:
* 全局 student-training.scss 用 !important 把 .workbench-actions /
* .task-actions 按钮锁成浅蓝大胶囊,scoped 规则无法取胜。
* 这里用 id 锚点(特异性含 #app)+ !important 稳定覆盖。
* ============================================================ */
/* 顶部"导出实训成果"原生按钮:全局 L592 锁为 34px 浅蓝胶囊 */
#app .studentLayout section.app-main:has(.product-process) .workbench-actions > button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
width: auto !important;
height: 36px !important;
min-height: 36px !important;
max-height: none !important;
padding: 0 14px !important;
border: 1px solid #d1d5db !important;
border-radius: 8px !important;
color: #374151 !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.product-process) .workbench-actions > button.outline-action:hover {
border-color: #1f2937 !important;
color: #1f2937 !important;
background: #f9fafb !important;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1) !important;
transform: none !important;
}
/* Step 02 标题行"导出列表":32px 紧凑小号;全局 L532 button.outline-action 锁 40px 胶囊 */
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 5px !important;
width: auto !important;
height: 32px !important;
min-height: 32px !important;
padding: 0 12px !important;
border: 1px solid #d1d5db !important;
border-radius: 8px !important;
color: #374151 !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: 12px !important;
font-weight: 600 !important;
line-height: 1 !important;
white-space: nowrap !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action .el-icon {
color: inherit !important;
font-size: 12px !important;
}
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action:hover {
border-color: #1f2937 !important;
color: #1f2937 !important;
background: #f9fafb !important;
transform: none !important;
}
/* --- 底部步骤按钮:40px 标准按钮(位置 A) --- */
#app .studentLayout section.app-main:has(.product-process) .task-actions {
gap: 14px !important;
margin-top: 20px !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions > button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
width: auto !important;
height: 40px !important;
min-height: 40px !important;
padding: 0 18px !important;
border: 1px solid rgba(31, 41, 55, 0.4) !important;
border-radius: 10px !important;
color: #1f2937 !important;
background: #ffffff !important;
box-shadow: none !important;
text-shadow: none !important;
font-size: 13px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions > button .el-icon {
display: inline-flex !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions > button:hover:not(:disabled) {
transform: translateY(-1px) !important;
border-color: #1f2937 !important;
color: #1f2937 !important;
background: #f3f4f6 !important;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12) !important;
}
/* 主按钮:墨黑实底白字 + 中性柔影 */
#app .studentLayout section.app-main:has(.product-process) .task-actions > button.btn-submit {
border-color: #1f2937 !important;
color: #ffffff !important;
background: #1f2937 !important;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28) !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions > button.btn-submit:hover:not(:disabled) {
border-color: #111827 !important;
color: #ffffff !important;
background: #111827 !important;
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.32) !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions > button:disabled {
cursor: not-allowed !important;
opacity: 0.5 !important;
transform: none !important;
box-shadow: none !important;
}
/* 窄屏:按钮纵向全宽(全局容器 min-width/布局均为 !important,须在此覆盖) */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.product-process) .workbench-head,
#app .studentLayout section.app-main:has(.product-process) .workbench-actions {
flex-direction: column !important;
align-items: stretch !important;
min-width: 0 !important;
width: 100% !important;
}
#app .studentLayout section.app-main:has(.product-process) .workbench-actions .material-actions {
display: grid !important;
width: 100% !important;
}
#app .studentLayout section.app-main:has(.product-process) .workbench-actions > button.outline-action,
#app .studentLayout section.app-main:has(.product-process) .step-title-actions button.outline-action,
#app .studentLayout section.app-main:has(.product-process) .task-actions > button {
width: 100% !important;
}
#app .studentLayout section.app-main:has(.product-process) .task-actions {
flex-direction: column !important;
align-items: stretch !important;
}
}
</style>