|
|
|
|
|
<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>
|