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/hardware-cost.vue

971 lines
58 KiB
Vue

<template>
<div class="hardware-cost-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge">BOM COSTING</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief
:background="taskBackground"
:goals="taskGoals"
:requirements="taskRequirement"
:show-material="false"
/>
<section class="task-card hardware-workbench">
<div class="workbench-head hardware-workbench-head">
<div>
<p class="section-eyebrow">BOM cost workspace</p>
<h2>智能硬件成本估算</h2>
</div>
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
</div>
<nav class="step-indicator" aria-label="智能硬件成本估算步骤">
<button type="button" :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" aria-label="切换至第 1 步:BOM 物料选型" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1">
<span class="step-marker" aria-hidden="true">01</span>
<span class="step-copy"><strong class="step-name" title="BOM 物料选型">BOM 物料选型</strong><span class="step-action">{{ currentStep === 1 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" aria-label="切换至第 2 步:批量成本核算" :aria-current="currentStep === 2 ? 'step' : undefined" @click="openStepTwo">
<span class="step-marker" aria-hidden="true">02</span>
<span class="step-copy"><strong class="step-name" title="批量成本核算">批量成本核算</strong><span class="step-action">{{ currentStep === 2 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: currentStep === 3, complete: currentStep > 3 }]" aria-label="切换至第 3 步:制造成本核算" :aria-current="currentStep === 3 ? 'step' : undefined" @click="openStepThree">
<span class="step-marker" aria-hidden="true">03</span>
<span class="step-copy"><strong class="step-name" title="制造成本核算">制造成本核算</strong><span class="step-action">{{ currentStep === 3 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: currentStep === 4, complete: currentStep > 4 }]" aria-label="切换至第 4 步:成本优化决策" :aria-current="currentStep === 4 ? 'step' : undefined" @click="openStepFour">
<span class="step-marker" aria-hidden="true">04</span>
<span class="step-copy"><strong class="step-name" title="成本优化决策">成本优化决策</strong><span class="step-action">{{ currentStep === 4 ? '当前步骤' : '点击切换' }} <span aria-hidden="true">→</span></span></span>
</button>
</nav>
<section v-if="currentStep === 1" class="step-card hardware-step-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">Step 01 · BOM selection</p>
<h2>核心元器件选型组合</h2>
</div>
</div>
<section class="cost-brief">
<span class="cost-brief__index">01</span>
<p>在目标成本约束下,权衡性能与成本,为每个元器件选择合适档位。请基于案例资料中的“元器件选型库”,完成核心元器件的选型组合。</p>
</section>
<section class="step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">BOM worksheet</p>
<h3>物料明细与金额核算</h3>
</div>
<span class="step-chip">{{ rows.length }} / {{ MAX_ROWS }} 行</span>
</div>
<div class="bom-toolbar">
<p>第一行是可编辑示例;金额会按“单价 × 数量”自动计算。保存本步骤前需填写至少 {{ MIN_ROWS }} 行。</p>
<div class="bom-toolbar__actions">
<button type="button" class="btn-nav" :disabled="!canAdd" @click="addRow">增加一行</button>
<button type="button" class="btn-nav btn-danger" :disabled="!canDelete" @click="removeLastRow">删除一行</button>
</div>
</div>
<div class="bom-table-wrap">
<table class="bom-table">
<thead><tr><th>序号</th><th>物料名称</th><th>选型规格参数</th><th>单价(元)</th><th>数量</th><th>金额(元)</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td class="row-index">{{ index + 1 }}</td>
<td><el-input v-model="row.materialName" maxlength="40" placeholder="请输入物料名称" /></td>
<td><el-input v-model="row.specification" maxlength="120" placeholder="请输入选型规格参数" /></td>
<td><el-input v-model="row.unitPrice" type="number" min="0" step="0.01" inputmode="decimal" placeholder="0.00" /></td>
<td><el-input-number v-model="row.quantity" :min="1" :max="9999" :precision="0" controls-position="right" /></td>
<td class="amount-cell">{{ formatAmount(rowAmount(row)) }}</td>
</tr>
</tbody>
<tfoot><tr><td colspan="5" class="total-label">合计金额</td><td class="total-amount">¥ {{ formatAmount(totalAmount) }}</td></tr></tfoot>
</table>
</div>
<p class="row-hint" :class="{ ready: rows.length >= MIN_ROWS }">
{{ rows.length >= MIN_ROWS ? '已达到最低填写要求,还可增加 ' + (MAX_ROWS - rows.length) + ' 行。' : '还需补充 ' + (MIN_ROWS - rows.length) + ' 行后才能保存。' }}
</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="saving" @click="restoreExample">恢复示例</button>
<button type="button" class="btn-nav btn-primary" :disabled="saving" @click="saveStepOne(false)">{{ saving ? "保存中…" : "校验并保存本步骤" }}</button>
<button type="button" class="btn-nav btn-primary" :disabled="saving" @click="saveStepOne(true)">保存并进入第 2 步</button>
</div>
</section>
</section>
<section v-else-if="currentStep === 2" class="step-card hardware-step-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">Step 02 · Volume costing</p>
<h2>批量成本核算与约束验证</h2>
</div>
<button type="button" class="btn-nav" :disabled="exporting || !costRows.length" @click="exportStepTwo">{{ exporting ? "导出中…" : "导出成本核算表" }}</button>
</div>
<section class="cost-brief">
<span class="cost-brief__index">02</span>
<p>物料名称自动同步第一步已保存的 BOM。请分别填写小批量试产(100 台)与大批量量产(10000 台)时,每一台产品所需物料的成本,并验证两种场景是否满足成本约束。</p>
</section>
<section class="constraint-panel">
<div class="constraint-panel__heading">
<p class="section-eyebrow">Cost constraints</p>
<span>两个成本约束均由学生填写,合计金额由系统自动计算。</span>
</div>
<div class="constraint-grid">
<label class="constraint-field">
<span>小批量试产 BOM 成本不高于</span>
<el-input v-model="stepTwo.smallBatchConstraint" type="number" min="0" step="0.01" inputmode="decimal" placeholder="请输入金额"><template #append>元/台</template></el-input>
</label>
<label class="constraint-field">
<span>大批量量产 BOM 成本不高于</span>
<el-input v-model="stepTwo.largeBatchConstraint" type="number" min="0" step="0.01" inputmode="decimal" placeholder="请输入金额"><template #append>元/台</template></el-input>
</label>
</div>
</section>
<section class="step-card">
<div class="step-title-row">
<div><p class="section-eyebrow">Unit cost comparison</p><h3>100 台与 10000 台单台成本</h3></div>
<span class="step-chip">{{ costRows.length }} 项物料同步自第一步</span>
</div>
<div class="bom-table-wrap">
<table class="bom-table batch-cost-table">
<thead><tr><th>物料名称</th><th>小批量 100 台(元/台)</th><th>大批量 10000 台(元/台)</th></tr></thead>
<tbody>
<tr v-for="row in costRows" :key="row.materialName">
<td class="material-name">{{ row.materialName }}</td>
<td><el-input v-model="row.smallBatchUnitCost" type="number" min="0" step="0.01" inputmode="decimal" placeholder="0.00" /></td>
<td><el-input v-model="row.largeBatchUnitCost" type="number" min="0" step="0.01" inputmode="decimal" placeholder="0.00" /></td>
</tr>
</tbody>
<tfoot><tr><td class="total-label">合计</td><td class="total-amount">¥ {{ formatAmount(smallBatchTotal) }}</td><td class="total-amount">¥ {{ formatAmount(largeBatchTotal) }}</td></tr></tfoot>
</table>
</div>
<div class="cost-result-grid">
<article class="cost-result" :class="resultClass('smallBatchWithinConstraint')">
<span>小批量核算</span><strong>¥ {{ formatAmount(smallBatchTotal) }} / {{ constraintDisplay(stepTwo.smallBatchConstraint) }}</strong><em>{{ validationLabel('smallBatchWithinConstraint') }}</em>
</article>
<article class="cost-result" :class="resultClass('largeBatchWithinConstraint')">
<span>大批量核算</span><strong>¥ {{ formatAmount(largeBatchTotal) }} / {{ constraintDisplay(stepTwo.largeBatchConstraint) }}</strong><em>{{ validationLabel('largeBatchWithinConstraint') }}</em>
</article>
</div>
<p v-if="stepTwoValidation?.message" class="validation-message" :class="{ failed: stepTwoValidation.valid === false }">{{ stepTwoValidation.message }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="savingStepTwo" @click="currentStep = 1">返回第 1 步</button>
<button type="button" class="btn-nav btn-primary" :disabled="savingStepTwo || !costRows.length" @click="saveStepTwo">{{ savingStepTwo ? "校验并保存中…" : "校验并保存本步骤" }}</button>
<button type="button" class="btn-nav btn-primary" :disabled="savingStepTwo || !costRows.length" @click="saveStepTwoAndNext">保存并进入第 3 步</button>
</div>
</section>
</section>
<section v-else-if="currentStep === 3" class="step-card hardware-step-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">Step 03 · Manufacturing costing</p>
<h2>制造费用与单件总成本核算</h2>
</div>
<button type="button" class="btn-nav" :disabled="exportingStepThree" @click="exportStepThree">
{{ exportingStepThree ? "导出中…" : "导出单件总成本表" }}
</button>
</div>
<section class="cost-brief">
<span class="cost-brief__index">03</span>
<p>硬件成本除物料 BOM 外,还需要覆盖制造费用。请填写两种批量下每台产品分摊的制造费用,系统将自动引用第 2 步 BOM 合计并计算单件总成本。</p>
</section>
<section class="step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Manufacturing allocation</p>
<h3>100 台与 10000 台制造费用分摊</h3>
</div>
<span class="step-chip">{{ stepThree.items.length }} / {{ MAX_MANUFACTURING_ROWS }} 项制造费用</span>
</div>
<div class="bom-toolbar">
<p>默认显示 4 项常见制造费用。可新增费用名称;保存前需完整填写至少 {{ MIN_MANUFACTURING_ROWS }} 项,最多 {{ MAX_MANUFACTURING_ROWS }} 项。物料 BOM 成本为第 2 步核算结果,只读引用。</p>
<div class="bom-toolbar__actions">
<button type="button" class="btn-nav" :disabled="!canAddManufacturingRow" @click="addManufacturingRow">增加一行</button>
<button type="button" class="btn-nav btn-danger" :disabled="!canDeleteManufacturingRow" @click="removeManufacturingRow">删除一行</button>
</div>
</div>
<div class="bom-table-wrap">
<table class="bom-table manufacturing-table">
<thead><tr><th>序号</th><th>费用名称</th><th>小批量试产 100 台(元/台)</th><th>大批量量产 10000 台(元/台)</th></tr></thead>
<tbody>
<tr v-for="(item, index) in stepThree.items" :key="item.id">
<td class="row-index">{{ index + 1 }}</td>
<td><el-input v-model="item.expenseName" maxlength="40" placeholder="请输入制造费用名称" /></td>
<td><el-input v-model="item.smallBatchUnitCost" type="number" min="0" step="0.01" inputmode="decimal" placeholder="0.00" /></td>
<td><el-input v-model="item.largeBatchUnitCost" type="number" min="0" step="0.01" inputmode="decimal" placeholder="0.00" /></td>
</tr>
</tbody>
<tfoot>
<tr><td class="row-index">{{ stepThree.items.length + 1 }}</td><td class="total-label">制造成本合计</td><td class="total-amount">¥ {{ formatAmount(smallBatchManufacturingCost) }}</td><td class="total-amount">¥ {{ formatAmount(largeBatchManufacturingCost) }}</td></tr>
<tr><td class="row-index">{{ stepThree.items.length + 2 }}</td><td class="total-label">物料 BOM 成本(引用第 2 步合计)</td><td class="reference-amount">¥ {{ formatAmount(smallBatchTotal) }}</td><td class="reference-amount">¥ {{ formatAmount(largeBatchTotal) }}</td></tr>
<tr><td class="row-index">{{ stepThree.items.length + 3 }}</td><td class="total-label">单件总成本(制造成本 + 物料 BOM 成本)</td><td class="total-amount">¥ {{ formatAmount(smallBatchUnitTotalCost) }}</td><td class="total-amount">¥ {{ formatAmount(largeBatchUnitTotalCost) }}</td></tr>
</tfoot>
</table>
</div>
<p class="row-hint" :class="{ ready: stepThree.items.length >= MIN_MANUFACTURING_ROWS }">
{{ stepThree.items.length >= MIN_MANUFACTURING_ROWS ? "已达到最低费用项要求。" : "还需补充 " + (MIN_MANUFACTURING_ROWS - stepThree.items.length) + " 项制造费用后才能保存。" }}
</p>
<p v-if="stepThreeValidation?.message" class="validation-message" :class="{ failed: stepThreeValidation.valid === false }">{{ stepThreeValidation.message }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="savingStepThree" @click="currentStep = 2">返回第 2 步</button>
<button type="button" class="btn-nav btn-primary" :disabled="savingStepThree" @click="saveStepThree(false)">{{ savingStepThree ? "校验并保存中…" : "校验并保存本步骤" }}</button>
<button type="button" class="btn-nav btn-primary" :disabled="savingStepThree" @click="saveStepThree(true)">保存并进入第 4 步</button>
</div>
</section>
</section>
<section v-else class="step-card hardware-step-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">Step 04 · Cost optimisation</p>
<h2>成本结构可视化与优化决策</h2>
</div>
<span class="step-chip">以第 2、3 步核算结果为依据</span>
</div>
<section class="cost-brief">
<span class="cost-brief__index">04</span>
<p>基于物料 BOM 成本与制造成本,上传小批量试产和大批量量产的成本结构图;随后明确优化优先级、预期效果及风险控制措施。</p>
</section>
<section class="cost-result-grid step-four-summary">
<article class="cost-result"><span>小批量试产单件总成本</span><strong>¥ {{ formatAmount(smallBatchUnitTotalCost) }}</strong><em>BOM ¥ {{ formatAmount(smallBatchTotal) }} · 制造 ¥ {{ formatAmount(smallBatchManufacturingCost) }}</em></article>
<article class="cost-result"><span>大批量量产单件总成本</span><strong>¥ {{ formatAmount(largeBatchUnitTotalCost) }}</strong><em>BOM ¥ {{ formatAmount(largeBatchTotal) }} · 制造 ¥ {{ formatAmount(largeBatchManufacturingCost) }}</em></article>
</section>
<section class="step-card chart-upload-section">
<div class="step-title-row">
<div><p class="section-eyebrow">Cost structure charts</p><h3>成本结构可视化</h3></div>
<span class="step-chip">图片支持点击放大预览</span>
</div>
<div class="chart-upload-grid">
<article class="chart-upload-card">
<h4>小批量试产成本结构图</h4>
<p>建议展示制造成本、物料 BOM 成本及主要费用构成。</p>
<label class="chart-upload-trigger" :class="{ uploading: uploadingChart === 'small' }">
<input type="file" accept="image/png,image/jpeg,image/webp" :disabled="uploadingChart === 'small'" @change="handleCostChartUpload($event, 'small')" />
{{ uploadingChart === 'small' ? '上传中…' : (stepFour.smallBatchChart.url ? '替换小批量图' : '上传小批量试产成本结构图') }}
</label>
<div v-if="stepFour.smallBatchChart.url" class="chart-preview">
<el-image :src="stepFour.smallBatchChart.url" :preview-src-list="[stepFour.smallBatchChart.url]" fit="cover" preview-teleported />
<div><span>{{ stepFour.smallBatchChart.name || '成本结构图' }}</span><button type="button" @click="clearCostChart('small')">移除</button></div>
</div>
</article>
<article class="chart-upload-card">
<h4>大批量量产成本结构图</h4>
<p>建议对比量产条件下的成本占比与降本空间。</p>
<label class="chart-upload-trigger" :class="{ uploading: uploadingChart === 'large' }">
<input type="file" accept="image/png,image/jpeg,image/webp" :disabled="uploadingChart === 'large'" @change="handleCostChartUpload($event, 'large')" />
{{ uploadingChart === 'large' ? '上传中…' : (stepFour.largeBatchChart.url ? '替换大批量图' : '上传大批量量产成本结构图') }}
</label>
<div v-if="stepFour.largeBatchChart.url" class="chart-preview">
<el-image :src="stepFour.largeBatchChart.url" :preview-src-list="[stepFour.largeBatchChart.url]" fit="cover" preview-teleported />
<div><span>{{ stepFour.largeBatchChart.name || '成本结构图' }}</span><button type="button" @click="clearCostChart('large')">移除</button></div>
</div>
</article>
</div>
</section>
<section class="step-card optimisation-section">
<div class="step-title-row">
<div><p class="section-eyebrow">Optimisation decision</p><h3>成本优化方案</h3></div>
</div>
<p class="decision-hint">请说明优化优先级、预期效果及风险控制措施,例如元器件降档、替换、减量或集成方案,并说明如何保证核心功能不受影响。</p>
<el-input v-model="stepFour.optimizationPlan" type="textarea" :rows="9" maxlength="3000" show-word-limit resize="vertical" placeholder="请填写整体成本优化思路、优先级、预期效果及风险控制措施" />
<div class="reference-downloads">
<button type="button" class="btn-nav" @click="downloadCostStructureGuide">下载实训操作指导</button>
<button type="button" class="btn-nav" @click="downloadCostStructureTemplate">下载 Excel 模板</button>
</div>
<p v-if="stepFourValidation?.message" class="validation-message" :class="{ failed: stepFourValidation.valid === false }">{{ stepFourValidation.message }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="savingStepFour" @click="currentStep = 3">返回第 3 步</button>
<button type="button" class="btn-nav btn-primary" :disabled="savingStepFour || !!uploadingChart" @click="saveStepFour">{{ savingStepFour ? '保存中…' : '保存成本优化决策' }}</button>
</div>
</section>
</section>
</section>
</main>
<TrainingAiSidebar
:task-key="TASK_KEY"
:has-saved-answer="hasSavedAnswer"
:progress-items="progressItems"
:study-tip="currentStudyTip"
reference-title="硬件成本核算提示"
reference-text="· BOM 金额 = 单价 × 数量<br />· 小批量和大批量成本需分别核算<br />· 优化方案应说明可执行的降本措施"
assist-text="建议先检查物料数量、单价和金额关系,再比较批量成本差异并形成可执行的成本优化结论。"
/>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, reactive, ref } from "vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateHardwareCostStepOne, validateHardwareCostStepTwo, validateHardwareCostStepThree, validateHardwareCostStepFour } from "@/api/studentTrainingAnswer";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "hardware-cost";
const HARDWARE_COST_GUIDE_URL = "/file/training-materials/hardware-cost/16.饼图制作操作说明.docx";
const HARDWARE_COST_TEMPLATE_URL = "/file/training-materials/hardware-cost/16.成本占比结构图绘制模版.xlsx";
const MIN_ROWS = 10;
const MAX_ROWS = 40;
const { proxy } = getCurrentInstance();
const taskConfig = ref({});
const currentStep = ref(1);
let rowSeed = 1;
const rows = ref([createExampleRow()]);
const savedStepOneItems = ref([]);
const saving = ref(false);
const savingStepTwo = ref(false);
const stepTwoSaved = ref(false);
const exporting = ref(false);
const savingStepThree = ref(false);
const exportingStepThree = ref(false);
const stepThreeSaved = ref(false);
const savingStepFour = ref(false);
const uploadingChart = ref("");
const stepTwoValidation = ref(null);
const stepTwo = reactive({ smallBatchConstraint: "", largeBatchConstraint: "", items: [] });
const stepThreeValidation = ref(null);
const stepFourValidation = ref(null);
const MIN_MANUFACTURING_ROWS = 6;
const MAX_MANUFACTURING_ROWS = 15;
let manufacturingSeed = 1;
const stepThree = reactive({ items: createDefaultManufacturingRows() });
const stepFour = reactive({
smallBatchChart: { url: "", name: "" },
largeBatchChart: { url: "", name: "" },
optimizationPlan: "",
});
const taskTitle = computed(() => taskConfig.value?.taskName || "智能硬件成本估算");
const taskBackground = computed(() => taskConfig.value?.background || "围绕智能硬件产品的核心元器件进行 BOM 选型与成本核算。");
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives ?? taskConfig.value?.goals, ["理解元器件性能、成本与产品定位之间的平衡", "完成可追溯的 BOM 选型明细"]));
const taskRequirement = computed(() => taskConfig.value?.requirements || taskConfig.value?.requirement || "基于案例资料中的元器件选型库完成 BOM 物料明细,并通过系统校验后保存。");
const hasSavedAnswer = computed(() => savedStepOneItems.value.length > 0 || stepTwoSaved.value || stepThreeSaved.value || Boolean(stepFour.optimizationPlan || stepFour.smallBatchChart.url || stepFour.largeBatchChart.url));
const currentStudyTip = computed(() => {
const tips = {
1: "先完成 BOM 物料选型,确保物料名称、规格、单价和数量之间的关系准确。",
2: "分别核算小批量和大批量的物料成本,重点比较批量差异对单件成本的影响。",
3: "结合制造环节成本与 BOM 成本,核算不同批量下的完整单件成本。",
4: "根据成本核算结果,给出有依据、可执行的硬件成本优化方案。",
};
return tips[currentStep.value] || tips[1];
});
const progressItems = computed(() => [
{ text: "BOM 物料选型", status: savedStepOneItems.value.length ? "done" : currentStep.value === 1 ? "doing" : "" },
{ text: "物料 BOM 成本核算", status: stepTwoSaved.value ? "done" : currentStep.value === 2 ? "doing" : "" },
{ text: "制造成本核算", status: stepThreeSaved.value ? "done" : currentStep.value === 3 ? "doing" : "" },
{ text: "成本优化决策", status: currentStep.value === 4 ? "doing" : "" },
]);
const canAdd = computed(() => rows.value.length < MAX_ROWS);
const canDelete = computed(() => rows.value.length > 1);
const totalAmount = computed(() => rows.value.reduce((total, row) => total + rowAmount(row), 0));
const costRows = computed(() => stepTwo.items);
const smallBatchTotal = computed(() => sumCost("smallBatchUnitCost"));
const largeBatchTotal = computed(() => sumCost("largeBatchUnitCost"));
const smallBatchManufacturingCost = computed(() => sumManufacturingCost("smallBatchUnitCost"));
const largeBatchManufacturingCost = computed(() => sumManufacturingCost("largeBatchUnitCost"));
const smallBatchUnitTotalCost = computed(() => Number((smallBatchManufacturingCost.value + smallBatchTotal.value).toFixed(2)));
const largeBatchUnitTotalCost = computed(() => Number((largeBatchManufacturingCost.value + largeBatchTotal.value).toFixed(2)));
const canAddManufacturingRow = computed(() => stepThree.items.length < MAX_MANUFACTURING_ROWS);
const canDeleteManufacturingRow = computed(() => stepThree.items.length > 4);
function createExampleRow() { return { id: "bom-" + rowSeed++, materialName: "主控芯片", specification: "32位 ARM Cortex-M", unitPrice: 5.6, quantity: 1 }; }
function createBlankRow() { return { id: "bom-" + rowSeed++, materialName: "", specification: "", unitPrice: "", quantity: 1 }; }
function createManufacturingRow(expenseName = "") { return { id: "manufacturing-" + manufacturingSeed++, expenseName, smallBatchUnitCost: "", largeBatchUnitCost: "" }; }
function createDefaultManufacturingRows() {
return ["设备折旧费", "生产运维费", "人员薪酬", "场地租赁费"].map((name) => createManufacturingRow(name));
}
function rowAmount(row) {
const unitPrice = Number(row?.unitPrice);
const quantity = Number(row?.quantity);
return !Number.isFinite(unitPrice) || !Number.isFinite(quantity) || unitPrice < 0 || quantity <= 0 ? 0 : Number((unitPrice * quantity).toFixed(2));
}
function sumCost(field) {
return Number(costRows.value.reduce((sum, row) => {
const value = Number(row?.[field]);
return sum + (Number.isFinite(value) && value >= 0 ? value : 0);
}, 0).toFixed(2));
}
function sumManufacturingCost(field) {
return Number(stepThree.items.reduce((sum, item) => {
const value = Number(item?.[field]);
return sum + (Number.isFinite(value) && value >= 0 ? value : 0);
}, 0).toFixed(2));
}
function formatAmount(value) { return Number(value || 0).toFixed(2); }
function constraintDisplay(value) {
const amount = Number(value);
return Number.isFinite(amount) && amount >= 0 ? "¥ " + formatAmount(amount) : "未填写约束";
}
function addRow() { if (canAdd.value) rows.value.push(createBlankRow()); }
function removeLastRow() { if (canDelete.value) rows.value.pop(); }
function restoreExample() { rows.value = [createExampleRow()]; }
function normalizeRows(items) {
return items.map((item) => ({ id: "bom-" + rowSeed++, materialName: String(item?.materialName || ""), specification: String(item?.specification || ""), unitPrice: item?.unitPrice ?? "", quantity: item?.quantity ?? 1 }));
}
function stepOnePayload(items = rows.value) {
return { items: items.map((item) => ({ materialName: String(item.materialName || "").trim(), specification: String(item.specification || "").trim(), unitPrice: Number(item.unitPrice), quantity: Number(item.quantity) })) };
}
function syncStepTwoRows(source = savedStepOneItems.value) {
const existing = new Map(stepTwo.items.map((item) => [item.materialName, item]));
stepTwo.items = source.map((item) => {
const materialName = String(item?.materialName || "").trim();
const prior = existing.get(materialName);
return { materialName, smallBatchUnitCost: prior?.smallBatchUnitCost ?? "", largeBatchUnitCost: prior?.largeBatchUnitCost ?? "" };
});
}
function resetStepFour() {
clearCostChart("small");
clearCostChart("large");
stepFour.optimizationPlan = "";
stepFourValidation.value = null;
}
function stepTwoPayload() {
return {
smallBatchConstraint: Number(stepTwo.smallBatchConstraint),
largeBatchConstraint: Number(stepTwo.largeBatchConstraint),
items: stepTwo.items.map((item) => ({ materialName: item.materialName, smallBatchUnitCost: Number(item.smallBatchUnitCost), largeBatchUnitCost: Number(item.largeBatchUnitCost) })),
};
}
function notifySuccess(message) { proxy?.$modal?.msgSuccess?.(message); }
function notifyError(message) { proxy?.$modal?.msgError?.(message); }
async function saveStepOne(goNext) {
saving.value = true;
try {
const checkResponse = await validateHardwareCostStepOne(stepOnePayload());
const validated = checkResponse?.data || {};
const normalizedItems = Array.isArray(validated.items) && validated.items.length ? normalizeRows(validated.items) : normalizeRows(stepOnePayload().items);
rows.value = normalizedItems;
const savedItems = stepOnePayload(normalizedItems).items;
await saveStudentTrainingAnswer(TASK_KEY, { currentStep: goNext ? 2 : 1, saveAction: "SAVE", step1Answer: JSON.stringify({ items: savedItems, totalAmount: Number(validated.totalAmount ?? totalAmount.value).toFixed(2) }), step2Answer: "", step3Answer: "", step4Answer: "" });
savedStepOneItems.value = savedItems;
syncStepTwoRows(savedItems);
stepTwoSaved.value = false;
stepThreeSaved.value = false;
resetStepFour();
notifySuccess(goNext ? "BOM 明细已保存,已进入第 2 步" : "BOM 明细已校验并保存");
if (goNext) currentStep.value = 2;
} catch (error) {
notifyError(error?.message || "保存失败,请检查 BOM 明细");
} finally {
saving.value = false;
}
}
function openStepTwo() {
if (!savedStepOneItems.value.length) { notifyError("请先完成并保存第 1 步 BOM 物料选型"); return; }
syncStepTwoRows();
currentStep.value = 2;
}
function openStepThree() {
if (!savedStepOneItems.value.length) { notifyError("请先完成并保存第 1 步 BOM 物料选型"); return; }
if (!stepTwoSaved.value) {
notifyError("请先完成并保存第 2 步物料 BOM 成本核算");
return;
}
currentStep.value = 3;
}
function openStepFour() {
if (!savedStepOneItems.value.length) { notifyError("请先完成并保存第 1 步 BOM 物料选型"); return; }
if (!stepTwoSaved.value) { notifyError("请先完成并保存第 2 步物料 BOM 成本核算"); return; }
if (!stepThreeSaved.value) { notifyError("请先完成并保存第 3 步制造成本核算"); return; }
currentStep.value = 4;
}
function applyValidationResult(validated) {
stepTwoValidation.value = validated;
if (!validated) return;
if (Array.isArray(validated.items)) {
stepTwo.items = validated.items.map((item) => ({ materialName: String(item?.materialName || ""), smallBatchUnitCost: item?.smallBatchUnitCost ?? "", largeBatchUnitCost: item?.largeBatchUnitCost ?? "" }));
}
if (validated.smallBatchConstraint !== undefined) stepTwo.smallBatchConstraint = validated.smallBatchConstraint;
if (validated.largeBatchConstraint !== undefined) stepTwo.largeBatchConstraint = validated.largeBatchConstraint;
}
async function saveStepTwo() {
savingStepTwo.value = true;
try {
const response = await validateHardwareCostStepTwo(stepTwoPayload());
const validated = response?.data || {};
applyValidationResult(validated);
if (!validated.valid) { notifyError(validated.message || "请调整物料成本或成本约束后重试"); return; }
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 2,
saveAction: "SAVE",
step2Answer: JSON.stringify({ items: stepTwo.items, smallBatchConstraint: validated.smallBatchConstraint, largeBatchConstraint: validated.largeBatchConstraint, smallBatchTotal: validated.smallBatchTotal, largeBatchTotal: validated.largeBatchTotal }),
step3Answer: "",
step4Answer: "",
});
stepTwoSaved.value = true;
stepThreeSaved.value = false;
resetStepFour();
notifySuccess("两种批量成本已校验并保存");
} catch (error) {
notifyError(error?.message || "成本核算校验失败,请检查填写内容");
} finally {
savingStepTwo.value = false;
}
}
async function saveStepTwoAndNext() {
await saveStepTwo();
if (stepTwoSaved.value) currentStep.value = 3;
}
function stepThreePayload() {
return {
items: stepThree.items.map((item) => ({
expenseName: String(item?.expenseName || "").trim(),
smallBatchUnitCost: Number(item?.smallBatchUnitCost),
largeBatchUnitCost: Number(item?.largeBatchUnitCost),
})),
};
}
function applyStepThreeValidation(validated) {
stepThreeValidation.value = validated;
if (Array.isArray(validated?.items)) {
stepThree.items = validated.items.map((item) => ({
id: "manufacturing-" + manufacturingSeed++,
expenseName: String(item?.expenseName || ""),
smallBatchUnitCost: item?.smallBatchUnitCost ?? "",
largeBatchUnitCost: item?.largeBatchUnitCost ?? "",
}));
}
}
function addManufacturingRow() { if (canAddManufacturingRow.value) stepThree.items.push(createManufacturingRow()); }
function removeManufacturingRow() { if (canDeleteManufacturingRow.value) stepThree.items.pop(); }
async function saveStepThree(goNext = false) {
savingStepThree.value = true;
try {
const response = await validateHardwareCostStepThree(stepThreePayload());
const validated = response?.data || {};
applyStepThreeValidation(validated);
if (!validated.valid) { notifyError(validated.message || "请检查制造费用填写内容"); return; }
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: goNext ? 4 : 3,
saveAction: "SAVE",
step3Answer: JSON.stringify({
items: stepThree.items,
smallBatchManufacturingCost: validated.smallBatchManufacturingCost,
largeBatchManufacturingCost: validated.largeBatchManufacturingCost,
smallBatchBomCost: validated.smallBatchBomCost,
largeBatchBomCost: validated.largeBatchBomCost,
smallBatchUnitTotalCost: validated.smallBatchUnitTotalCost,
largeBatchUnitTotalCost: validated.largeBatchUnitTotalCost,
}),
step4Answer: "",
});
stepThreeSaved.value = true;
resetStepFour();
notifySuccess("制造成本与单件总成本已校验并保存");
if (goNext) currentStep.value = 4;
} catch (error) {
notifyError(error?.message || "制造成本核算校验失败,请检查填写内容");
} finally {
savingStepThree.value = false;
}
}
function stepFourPayload() {
return {
smallBatchChartUrl: String(stepFour.smallBatchChart.url || "").trim(),
largeBatchChartUrl: String(stepFour.largeBatchChart.url || "").trim(),
optimizationPlan: String(stepFour.optimizationPlan || "").trim(),
};
}
function chartTarget(type) { return type === "small" ? stepFour.smallBatchChart : stepFour.largeBatchChart; }
function clearCostChart(type) {
const chart = chartTarget(type);
if (String(chart.url || "").startsWith("blob:")) URL.revokeObjectURL(chart.url);
chart.url = "";
chart.name = "";
}
async function handleCostChartUpload(event, type) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
const acceptedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!acceptedTypes.includes(file.type)) { notifyError("请上传 PNG、JPG 或 WEBP 格式的图片"); return; }
if (file.size > 5 * 1024 * 1024) { notifyError("图片大小不能超过 5MB"); return; }
uploadingChart.value = type;
try {
let url;
if (isStudentDemo()) {
url = URL.createObjectURL(file);
} else {
const formData = new FormData();
formData.append("file", file);
const response = await uploadStudentTrainingFile(formData);
url = response?.url || response?.data?.url;
if (!url) throw new Error("上传成功但未返回文件地址");
}
clearCostChart(type);
const chart = chartTarget(type);
chart.url = url;
chart.name = file.name;
notifySuccess("成本结构图已上传,可点击图片放大预览");
} catch (error) {
notifyError(error?.message || "图片上传失败,请稍后重试");
} finally {
uploadingChart.value = "";
}
}
async function saveStepFour() {
savingStepFour.value = true;
try {
const payload = stepFourPayload();
const response = await validateHardwareCostStepFour(payload);
const validated = response?.data || {};
stepFourValidation.value = validated;
if (!validated.valid) { notifyError(validated.message || "请完善成本结构图和优化方案"); return; }
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 4,
saveAction: "SUBMIT",
submitted: true,
step4Answer: JSON.stringify({
smallBatchChart: { ...stepFour.smallBatchChart },
largeBatchChart: { ...stepFour.largeBatchChart },
optimizationPlan: payload.optimizationPlan,
}),
});
notifySuccess("成本优化决策已保存");
} catch (error) {
notifyError(error?.message || "成本优化决策保存失败,请检查填写内容");
} finally {
savingStepFour.value = false;
}
}
function resultClass(field) { return stepTwoValidation.value?.[field] === true ? "passed" : stepTwoValidation.value?.[field] === false ? "failed" : ""; }
function validationLabel(field) { return stepTwoValidation.value?.[field] === true ? "满足成本约束" : stepTwoValidation.value?.[field] === false ? "超出成本约束" : "待校验"; }
async function exportStepTwo() {
exporting.value = true;
try {
const XLSX = await import("xlsx");
const data = [
["智能硬件成本估算 - 批量成本核算"],
["小批量试产 BOM 成本约束(元/台)", stepTwo.smallBatchConstraint || ""],
["大批量量产 BOM 成本约束(元/台)", stepTwo.largeBatchConstraint || ""],
[],
["物料名称", "小批量 100 台(元/台)", "大批量 10000 台(元/台)"],
...costRows.value.map((item) => [item.materialName, item.smallBatchUnitCost, item.largeBatchUnitCost]),
["合计", smallBatchTotal.value, largeBatchTotal.value],
];
const worksheet = XLSX.utils.aoa_to_sheet(data);
worksheet["!cols"] = [{ wch: 24 }, { wch: 25 }, { wch: 28 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "批量成本核算");
XLSX.writeFile(workbook, "智能硬件成本估算-批量成本核算.xlsx");
notifySuccess("成本核算表已导出");
} catch (error) {
notifyError(error?.message || "导出失败,请稍后重试");
} finally {
exporting.value = false;
}
}
async function exportStepThree() {
exportingStepThree.value = true;
try {
const XLSX = await import("xlsx");
const data = [
["智能硬件成本估算 - 制造成本与单件总成本"],
[],
["费用名称", "小批量试产 100 台(元/台)", "大批量量产 10000 台(元/台)"],
...stepThree.items.map((item) => [item.expenseName, item.smallBatchUnitCost, item.largeBatchUnitCost]),
["制造成本合计", smallBatchManufacturingCost.value, largeBatchManufacturingCost.value],
["物料 BOM 成本(引用第 2 步合计)", smallBatchTotal.value, largeBatchTotal.value],
["单件总成本", smallBatchUnitTotalCost.value, largeBatchUnitTotalCost.value],
];
const worksheet = XLSX.utils.aoa_to_sheet(data);
worksheet["!cols"] = [{ wch: 26 }, { wch: 28 }, { wch: 30 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "单件总成本");
XLSX.writeFile(workbook, "智能硬件成本估算-制造成本与单件总成本.xlsx");
notifySuccess("单件总成本表已导出");
} catch (error) {
notifyError(error?.message || "导出失败,请稍后重试");
} finally {
exportingStepThree.value = false;
}
}
function downloadTextFile(name, content) {
const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
function downloadCostStructureGuide() {
downloadBuiltInFile(HARDWARE_COST_GUIDE_URL, "16.饼图制作操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
function downloadCostStructureTemplate() {
downloadBuiltInFile(HARDWARE_COST_TEMPLATE_URL, "16.成本占比结构图绘制模版.xlsx");
}
async function loadAnswer() {
const response = await getStudentTrainingAnswer(TASK_KEY);
const answerRecord = response?.data || {};
if (answerRecord.step1Answer) {
try {
const saved = typeof answerRecord.step1Answer === "string" ? JSON.parse(answerRecord.step1Answer) : answerRecord.step1Answer;
if (Array.isArray(saved?.items) && saved.items.length) {
rows.value = normalizeRows(saved.items);
savedStepOneItems.value = stepOnePayload(rows.value).items;
}
} catch (_) { /* Ignore legacy malformed answer content. */ }
}
syncStepTwoRows();
if (answerRecord.step2Answer) {
try {
const saved = typeof answerRecord.step2Answer === "string" ? JSON.parse(answerRecord.step2Answer) : answerRecord.step2Answer;
stepTwo.smallBatchConstraint = saved?.smallBatchConstraint ?? "";
stepTwo.largeBatchConstraint = saved?.largeBatchConstraint ?? "";
if (Array.isArray(saved?.items)) {
const costByName = new Map(saved.items.map((item) => [item?.materialName, item]));
stepTwo.items = savedStepOneItems.value.map((item) => {
const savedItem = costByName.get(item.materialName);
return { materialName: item.materialName, smallBatchUnitCost: savedItem?.smallBatchUnitCost ?? "", largeBatchUnitCost: savedItem?.largeBatchUnitCost ?? "" };
});
stepTwoSaved.value = true;
}
} catch (_) { /* Keep the synchronised blank cost table. */ }
}
if (answerRecord.step3Answer) {
try {
const saved = typeof answerRecord.step3Answer === "string" ? JSON.parse(answerRecord.step3Answer) : answerRecord.step3Answer;
if (Array.isArray(saved?.items) && saved.items.length) {
stepThree.items = saved.items.map((item) => ({
id: "manufacturing-" + manufacturingSeed++,
expenseName: String(item?.expenseName || ""),
smallBatchUnitCost: item?.smallBatchUnitCost ?? "",
largeBatchUnitCost: item?.largeBatchUnitCost ?? "",
}));
stepThreeSaved.value = true;
}
} catch (_) { /* Keep the default manufacturing cost table. */ }
}
if (answerRecord.step4Answer) {
try {
const saved = typeof answerRecord.step4Answer === "string" ? JSON.parse(answerRecord.step4Answer) : answerRecord.step4Answer;
stepFour.smallBatchChart.url = String(saved?.smallBatchChart?.url || "");
stepFour.smallBatchChart.name = String(saved?.smallBatchChart?.name || "");
stepFour.largeBatchChart.url = String(saved?.largeBatchChart?.url || "");
stepFour.largeBatchChart.name = String(saved?.largeBatchChart?.name || "");
stepFour.optimizationPlan = String(saved?.optimizationPlan || "");
} catch (_) { /* Keep the blank Step 4 decision form. */ }
}
if (Number(answerRecord.currentStep) === 4 && stepThreeSaved.value) currentStep.value = 4;
else if (Number(answerRecord.currentStep) === 3 && stepTwoSaved.value) currentStep.value = 3;
else if (Number(answerRecord.currentStep) === 2 && savedStepOneItems.value.length) currentStep.value = 2;
}
onMounted(async () => {
try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || {}; } catch (_) { taskConfig.value = {}; }
try { await loadAnswer(); } catch (_) { /* A missing saved answer should not block the worksheet. */ }
});
</script>
<style scoped>
.hardware-cost-page{min-height:100%;color:#2c4a6a;background:#ffffff}.training-core{width:min(1480px,calc(100vw - 64px));margin:0 auto;padding:28px 0 64px}.task-header{display:flex;align-items:flex-end;gap:14px;margin-bottom:18px}.task-badge,.section-eyebrow{margin:0;color:#1565c0;font-size:var(--student-fs-caption);font-weight:800;letter-spacing:.12em;text-transform:uppercase}.task-badge{padding:7px 10px;border:1px solid #cfe2f5;border-radius:8px;background:#e3f2fd}.task-title{margin:0;color:#1e3a5f;font-size:var(--student-fs-display);line-height:1.2}.step-navigation{display:flex;align-items:center;margin:22px 0 16px;padding:10px 16px;border:1px solid #cfe2f5;border-radius:14px;background:#f0f6fd}.step-nav-item{display:inline-flex;align-items:center;gap:9px;border:0;padding:8px 10px;color:#5a7a9a;font-weight:800;cursor:pointer;background:transparent}.step-nav-item span{display:grid;place-items:center;width:24px;height:24px;border:1px solid currentColor;border-radius:50%;font-size:var(--student-fs-caption)}.step-nav-item.active{color:#1565c0}.step-nav-item.active span{color:#ffffff;border-color:#1565c0;background:#1565c0}.step-connector{width:42px;height:1px;margin:0 4px;background:linear-gradient(90deg,#1e88e5,transparent)}.task-card{padding:26px;border:1px solid #cfe2f5;border-radius:18px;background:linear-gradient(145deg,#f0f6fd,#f0f6fd);box-shadow:0 18px 48px rgba(0,0,0,0.08)}.workbench-head,.step-title-row,.bom-toolbar,.task-actions{display:flex;align-items:center;justify-content:space-between;gap:18px}.workbench-head{margin-bottom:22px}.workbench-head h2,.step-title-row h3{margin:5px 0 0;color:#1e3a5f}.workbench-head h2{font-size:var(--student-fs-h1)}.cost-brief{display:flex;align-items:flex-start;gap:12px;margin-bottom:22px;padding:15px 18px;border:1px solid #cfe2f5;border-radius:12px;background:#e3f2fd}.cost-brief__index{display:grid;flex:none;place-items:center;width:28px;height:28px;border-radius:50%;color:#ffffff;font-size:var(--student-fs-caption);font-weight:800;background:#1565c0}.cost-brief p{margin:2px 0 0;color:#5a7a9a;line-height:1.75}.step-card,.constraint-panel{padding:22px;border:1px solid #cfe2f5;border-radius:14px;background:#e3f2fd}.constraint-panel{margin-bottom:18px}.constraint-panel__heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.constraint-panel__heading span{color:#5a7a9a;font-size:var(--student-fs-body-sm)}.constraint-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.constraint-field{display:grid;gap:8px;color:#2c4a6a;font-weight:700}.step-title-row{margin-bottom:16px}.step-title-row h3{font-size:21px}.step-chip{padding:8px 11px;border:1px solid #cfe2f5;border-radius:99px;color:#1565c0;font-size:var(--student-fs-body-sm);background:#e3f2fd}.bom-toolbar{align-items:flex-start;margin-bottom:14px}.bom-toolbar p{max-width:760px;margin:0;color:#5a7a9a;line-height:1.65}.bom-toolbar__actions{display:flex;flex:none;gap:10px}.btn-nav{border:1px solid #cfe2f5;border-radius:8px;padding:9px 15px;color:#2c4a6a;font-weight:700;cursor:pointer;background:#e3f2fd;transition:.18s ease}.btn-nav:hover:not(:disabled){transform:translateY(-1px);border-color:#1e88e5;background:#bbdefb}.btn-nav:disabled{cursor:not-allowed;opacity:.46}.btn-primary{border-color:#1e88e5;color:#ffffff;background:linear-gradient(90deg,#1e88e5,#42a5f5)}.btn-danger{border-color:rgba(255,126,143,.48);color:#ffc9d0;background:rgba(132,38,57,.32)}.bom-table-wrap{overflow-x:auto;border:1px solid #cfe2f5;border-radius:10px}.bom-table{width:100%;min-width:980px;border-collapse:collapse;table-layout:fixed}.bom-table th{padding:13px 10px;color:#ffffff;font-weight:800;background:#1565c0}.bom-table th:nth-child(1){width:7%}.bom-table th:nth-child(2){width:17%}.bom-table th:nth-child(3){width:31%}.bom-table th:nth-child(4){width:16%}.bom-table th:nth-child(5){width:13%}.bom-table th:nth-child(6){width:16%}.batch-cost-table{min-width:720px}.batch-cost-table th:nth-child(1){width:34%}.batch-cost-table th:nth-child(2),.batch-cost-table th:nth-child(3){width:33%}.bom-table td
.manufacturing-table{min-width:820px}.manufacturing-table th:nth-child(1){width:8%}.manufacturing-table th:nth-child(2){width:32%}.manufacturing-table th:nth-child(3),.manufacturing-table th:nth-child(4){width:30%}.reference-amount{text-align:center;color:#5a7a9a;font-size:var(--student-fs-h2);font-weight:800;background:#bbdefb!important}
.step-four-summary{margin:0 0 18px}.chart-upload-section,.optimization-section{margin-top:18px}.chart-upload-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.chart-upload-card{padding:18px;border:1px solid #cfe2f5;border-radius:13px;background:#e3f2fd}.chart-upload-card h4{margin:0;color:#1e3a5f;font-size:17px}.chart-upload-card p{min-height:40px;margin:7px 0 15px;color:#5a7a9a;font-size:var(--student-fs-body-sm);line-height:1.65}.chart-upload-trigger{display:flex;align-items:center;justify-content:center;min-height:42px;border:1px dashed #cfe2f5;border-radius:8px;color:#2c4a6a;font-weight:800;cursor:pointer;background:#e3f2fd;transition:.18s ease}.chart-upload-trigger:hover{border-color:#1e88e5;background:#bbdefb}.chart-upload-trigger.uploading{pointer-events:none;opacity:.65}.chart-upload-trigger input{display:none}.chart-preview{display:grid;grid-template-columns:104px 1fr auto;gap:12px;align-items:center;margin-top:14px;padding:10px;border:1px solid #cfe2f5;border-radius:9px;background:#e3f2fd}.chart-preview :deep(.el-image){width:104px;height:72px;border-radius:6px;cursor:zoom-in;background:#ffffff}.chart-preview>div{display:grid;gap:4px;min-width:0}.chart-preview span{overflow:hidden;color:#2c4a6a;font-size:var(--student-fs-body-sm);font-weight:700;text-overflow:ellipsis;white-space:nowrap}.chart-preview small{color:#5a7a9a;font-size:var(--student-fs-caption)}.chart-preview button{border:0;color:#ffc6ce;font-size:var(--student-fs-body-sm);cursor:pointer;background:transparent}.decision-hint{margin:8px 0 0;color:#5a7a9a;font-size:var(--student-fs-body-sm)}.reference-downloads{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}.reference-downloads .btn-nav{display:inline-flex;align-items:center;gap:7px}.reference-downloads .btn-nav::before{content:'↓';font-size:var(--student-fs-h2)}.reference-downloads .btn-nav:hover{color:#1e3a5f}
@media(max-width:780px){.chart-upload-grid{grid-template-columns:1fr}.chart-preview{grid-template-columns:86px 1fr}.chart-preview :deep(.el-image){width:86px;height:62px}.chart-preview button{grid-column:2;text-align:left;padding:0}.reference-downloads .btn-nav{flex:1;justify-content:center}}
/* 与 foundation/new-product-survey 保持同一套步骤条局部细节;公共规则仍由 student-training.scss 提供。 */
/* AI 侧栏启用后,主内容必须收敛到所属网格列,避免固定宽度侵入侧栏区域。 */
.hardware-cost-page .training-core {
box-sizing: border-box;
min-width: 0;
width: 100%;
margin: 0;
padding: 28px;
border: 1px solid #cfe2f5;
border-radius: 36px;
background: #f0f6fd;
box-shadow: inset 0 1px 0 #cfe2f5, 0 22px 48px rgba(0,0,0,0.08);
}
.hardware-cost-page .task-header {
margin-bottom: 24px;
}
.hardware-cost-page .task-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
margin: 0;
padding: 4px 14px;
border: 1px solid #cfe2f5;
border-radius: 999px;
color: #2c4a6a;
background: #e3f2fd;
}
.hardware-cost-page .task-title {
margin: 12px 0 0;
color: #1e3a5f;
font-size: var(--student-fs-display);
font-weight: 900;
line-height: 1.3;
}
.hardware-cost-page .hardware-workbench {
margin-top: 28px;
padding: 18px 22px 22px;
border: 1px solid #cfe2f5;
border-radius: 26px;
background: #f0f6fd;
}
.hardware-cost-page .hardware-workbench-head {
margin-bottom: 22px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(0,180,255,.2);
}
.hardware-cost-page .hardware-workbench-head h2 {
margin: 5px 0 0;
color: #1e3a5f;
font-size: var(--student-fs-h1);
line-height: 1.3;
}
.hardware-cost-page .hardware-workbench > .step-indicator {
margin: 0 0 30px !important;
}
.hardware-cost-page .hardware-step-card {
padding: 24px;
border: 1px solid #cfe2f5;
border-radius: 26px;
background: #f0f6fd;
}
.step-marker {
display: inline-flex;
flex: 0 0 38px;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 1px solid #cfe2f5;
border-radius: 50%;
color: #5a7a9a;
background: #e3f2fd;
box-shadow: inset 0 0 12px #cfe2f5;
font-size: var(--student-fs-caption);
font-weight: 900;
letter-spacing: 0.04em;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.step-copy { display: grid; min-width: 0; gap: 4px; margin-left: 10px; }
.step-name { display: -webkit-box; overflow: hidden; color: inherit; font-size: var(--student-fs-body); font-weight: 800; line-height: 1.25; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.step-action { color: #1565c0; font-size: var(--student-fs-caption); font-weight: 800; letter-spacing: 0.06em; line-height: 1; transition: color 0.2s ease; }
.step-tab:hover {
border-color: #cfe2f5;
color: #1e3a5f;
background: #bbdefb;
box-shadow: 0 7px 18px rgba(0,0,0,0.1), inset 0 1px 0 #cfe2f5;
transform: translateY(-2px);
}
.step-tab:hover .step-action { color: #1565c0; }
.step-tab:focus-visible { outline: 2px solid #1565c0; outline-offset: 4px; }
.step-tab.active .step-marker {
border-color: #1565c0;
color: #ffffff;
background: linear-gradient(135deg, #1e88e5, #1565c0);
box-shadow: 0 0 0 5px #cfe2f5, 0 0 18px #cfe2f5;
}
.step-tab.complete { color: #2c4a6a; }
.step-tab.complete .step-marker {
border-color: #43a047;
color: #ffffff;
background: #43a047;
box-shadow: 0 0 12px #43a047;
}
.step-connector {
position: relative;
flex: 0 1 40px;
min-width: 18px;
height: 2px;
margin: 0 8px;
overflow: visible;
background: #cfe2f5;
}
.step-connector::after {
position: absolute;
top: 50%;
right: -1px;
width: 6px;
height: 6px;
border-top: 1px solid #1565c0;
border-right: 1px solid #1565c0;
content: '';
transform: translateY(-50%) rotate(45deg);
}
.step-tab.complete + .step-connector {
background: linear-gradient(90deg, #43a047, #1e88e5);
box-shadow: 0 0 9px #26c6da;
}
.step-tab.complete + .step-connector::after { border-color: #26c6da; }
@media (max-width: 900px) {
.step-indicator {
flex-direction: column;
align-items: stretch;
width: 100%;
margin: 0 0 32px;
padding: 16px;
border-radius: 20px;
overflow-x: visible;
}
.step-tab {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.step-connector {
flex: 0 0 20px;
align-self: flex-start;
width: 2px;
min-width: 0;
height: 20px;
margin: 4px 0 4px 18px;
}
.step-connector::after {
top: auto;
right: auto;
bottom: -1px;
left: 50%;
transform: translateX(-50%) rotate(135deg);
}
}
</style>