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

1590 lines
65 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="hardware-cost-page student-training-shell">
<main class="hardware-core">
<section class="task-header">
<div class="task-head-main">
<span class="task-badge"><el-icon><DataAnalysis /></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="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"
: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"
/>
</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 { DataAnalysis } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.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 briefOpen = ref(false);
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 lang="scss" scoped>
/* ===== 页面级令牌(墨黑极简主题) ===== */
.hardware-cost-page {
--student-accent: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 主容器:gap 单列,无 border/bg/阴影 */
.hardware-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
margin: 0;
width: 100%;
}
/* ===== task-header ===== */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge,
.section-eyebrow {
color: var(--student-text-secondary);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.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;
text-transform: none;
letter-spacing: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
/* ===== 工作台 / 步骤卡片 ===== */
.hardware-workbench {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
margin: 0;
display: grid;
gap: 14px;
}
.workbench-head,
.step-title-row,
.bom-toolbar,
.task-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.workbench-head { margin-bottom: 4px; }
.workbench-head h2 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
display: grid;
gap: 10px;
}
.step-title-row { margin-top: 4px; }
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.step-chip {
border: 1px solid var(--student-panel-border);
border-radius: 6px;
padding: 3px 10px;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
background: transparent;
}
/* cost-brief 改成 research-guide 同款引导块 */
.cost-brief {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border: 0;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
background: var(--student-accent-soft);
}
.cost-brief__index {
display: grid;
flex: none;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: var(--student-accent-deep);
font-size: 11px;
font-weight: 700;
background: #ffffff;
border: 1px solid var(--student-panel-border);
}
.cost-brief p {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 表单约束 ===== */
.constraint-panel {
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
box-shadow: none;
}
.constraint-panel__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.constraint-panel__heading span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.constraint-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.constraint-field {
display: grid;
gap: 6px;
color: var(--student-text);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
/* ===== BOM 工具栏 ===== */
.bom-toolbar { align-items: flex-start; margin-bottom: 4px; }
.bom-toolbar p {
max-width: 760px;
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.bom-toolbar__actions { display: flex; flex: none; gap: 8px; }
/* ===== 表格 ===== */
.bom-table-wrap { overflow-x: auto; border: 1px solid var(--student-panel-border); border-radius: 8px; }
.bom-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
table-layout: fixed;
}
.bom-table th {
padding: 10px 10px;
color: var(--student-text);
font-weight: 800;
font-size: var(--student-fs-body-sm);
background: #fafbfc;
border-bottom: 1px solid var(--student-panel-border);
text-align: left;
}
.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: 640px; }
.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%; }
.manufacturing-table { min-width: 760px; }
.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%; }
.bom-table td {
padding: 8px 10px;
border-top: 1px solid var(--student-panel-border);
background: transparent;
font-size: var(--student-fs-body-sm);
}
.row-index,
.amount-cell,
.total-amount {
text-align: center;
font-variant-numeric: tabular-nums;
}
.row-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: #f3f4f6;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 700;
}
.material-name {
padding-left: 14px !important;
color: var(--student-text);
font-weight: 600;
}
.amount-cell { color: var(--student-text); font-weight: 700; }
.total-label {
padding-right: 14px !important;
color: var(--student-text);
font-weight: 700;
text-align: right;
background: transparent !important;
}
.total-amount {
color: var(--student-accent-deep);
font-size: var(--student-fs-h2);
font-weight: 800;
background: transparent !important;
}
.reference-amount {
text-align: center;
color: var(--student-text-secondary);
font-size: var(--student-fs-h2);
font-weight: 700;
background: transparent !important;
}
.row-hint {
margin: 6px 0 0;
color: #b45309;
font-size: var(--student-fs-body-sm);
}
.row-hint.ready { color: var(--student-success); }
.validation-message {
margin: 6px 0 0;
color: var(--student-success);
font-size: var(--student-fs-body);
}
.validation-message.failed { color: var(--student-danger); }
/* ===== 输入框透明化 ===== */
:deep(.el-input__wrapper),
:deep(.el-input-number) {
box-shadow: 0 0 0 1px var(--student-panel-border) inset !important;
background: transparent !important;
border-radius: 6px;
}
:deep(.el-input__wrapper.is-focus),
:deep(.el-input-number.is-focus) {
box-shadow: 0 0 0 1px var(--student-accent) inset !important;
}
:deep(.el-input__inner) {
color: var(--student-text) !important;
font-size: var(--student-fs-body-sm);
}
:deep(.el-input-number) { width: 100%; }
:deep(.el-input-group__append) {
color: var(--student-text-secondary);
border-color: var(--student-panel-border);
background: var(--student-accent-soft);
font-size: var(--student-fs-body-sm);
}
/* ===== 按钮(scoped 基础,全局 task-actions 会墨黑进一步覆盖) ===== */
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
padding: 0 16px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease,
transform 0.16s ease, box-shadow 0.16s ease;
}
.btn-nav:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.btn-nav.btn-primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.btn-nav.btn-primary:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
.btn-nav.btn-danger {
border-color: rgba(217, 85, 76, 0.4);
color: var(--student-danger);
background: #ffffff;
}
.btn-nav.btn-danger:hover:not(:disabled) {
border-color: var(--student-danger);
background: rgba(217, 85, 76, 0.08);
color: var(--student-danger);
}
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.task-actions {
justify-content: flex-end;
margin-top: 14px;
}
/* ===== cost-result 统计卡片 ===== */
.cost-result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.cost-result {
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.cost-result span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.cost-result strong {
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
.cost-result em {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-style: normal;
}
.cost-result.passed {
border-color: rgba(74, 157, 110, 0.5);
background: rgba(74, 157, 110, 0.06);
}
.cost-result.passed em { color: var(--student-success); font-weight: 600; }
.cost-result.failed {
border-color: rgba(217, 85, 76, 0.5);
background: rgba(217, 85, 76, 0.06);
}
.cost-result.failed em { color: var(--student-danger); font-weight: 600; }
.step-four-summary { margin: 0 0 4px; }
/* ===== 第 4 步:图表上传 ===== */
.chart-upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.chart-upload-card {
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.chart-upload-card h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.chart-upload-card p {
min-height: 40px;
margin: 6px 0 12px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.chart-upload-trigger {
display: flex;
align-items: center;
justify-content: center;
min-height: 40px;
border: 1px dashed var(--student-panel-border);
border-radius: 8px;
color: var(--student-accent);
font-weight: 700;
cursor: pointer;
background: #ffffff;
transition: border-color 0.16s ease, background 0.16s ease;
}
.chart-upload-trigger:hover:not(.uploading) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
.chart-upload-trigger.uploading {
pointer-events: none;
opacity: 0.65;
}
.chart-upload-trigger input { display: none; }
.chart-preview {
display: grid;
grid-template-columns: 104px 1fr auto;
gap: 12px;
align-items: center;
margin-top: 12px;
padding: 10px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #fafbfc;
}
.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: var(--student-text);
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-preview button {
border: 0;
color: var(--student-danger);
font-size: var(--student-fs-body-sm);
font-weight: 600;
cursor: pointer;
background: transparent;
padding: 0;
}
/* ===== 优化决策 textarea ===== */
.decision-hint {
margin: 6px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.reference-downloads {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.reference-downloads .btn-nav {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* ===== 响应式 ===== */
@media (max-width: 840px) {
.constraint-grid,
.cost-result-grid,
.chart-upload-grid {
grid-template-columns: 1fr;
}
.bom-toolbar,
.bom-toolbar__actions {
flex-direction: column;
align-items: stretch;
}
.task-actions { flex-wrap: wrap; }
.task-actions .btn-nav { flex: 1 1 auto; }
}
@media (max-width: 720px) {
.task-header { flex-direction: column; align-items: flex-start; }
.step-chip { display: none; }
.bom-table-wrap .bom-table { min-width: 720px; }
}
</style>
<style lang="scss">
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.hardware-cost-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector { display: none !important; }
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action { display: none !important; }
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.hardware-cost-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.hardware-cost-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部工具栏 el-button 换肤 ===== */
.hardware-cost-page .training-material-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.hardware-cost-page .training-material-button.el-button .el-icon {
font-size: 14px;
color: inherit;
}
.hardware-cost-page .training-material-button.el-button:hover,
.hardware-cost-page .training-material-button.el-button:focus {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
.hardware-cost-page .training-material-button.el-button.is-loading {
border-color: #d1d5db;
color: #374151;
background: #ffffff;
}
</style>