|
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<nav class="step-navigation" aria-label="智能硬件成本估算步骤">
|
|
|
|
|
|
<button type="button" class="step-nav-item" :class="{ active: currentStep === 1 }" @click="currentStep = 1">
|
|
|
|
|
|
<span>01</span> BOM 物料选型
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<i class="step-connector"></i>
|
|
|
|
|
|
<button type="button" class="step-nav-item" :class="{ active: currentStep === 2 }" @click="openStepTwo">
|
|
|
|
|
|
<span>02</span> 批量成本核算
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<i class="step-connector"></i>
|
|
|
|
|
|
<button type="button" class="step-nav-item" :class="{ active: currentStep === 3 }" @click="openStepThree">
|
|
|
|
|
|
<span>03</span> 制造成本核算
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<i class="step-connector"></i>
|
|
|
|
|
|
<button type="button" class="step-nav-item" :class="{ active: currentStep === 4 }" @click="openStepFour">
|
|
|
|
|
|
<span>04</span> 成本优化决策
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="currentStep === 1" class="task-card">
|
|
|
|
|
|
<div class="workbench-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">Step 01 · BOM selection</p>
|
|
|
|
|
|
<h2>核心元器件选型组合</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
|
|
|
|
|
|
</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="task-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="task-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="task-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>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { computed, getCurrentInstance, onMounted, reactive, ref } from "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 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?.goals, ["理解元器件性能、成本与产品定位之间的平衡", "完成可追溯的 BOM 选型明细"]));
|
|
|
|
|
|
const taskRequirement = computed(() => taskConfig.value?.requirement || "基于案例资料中的元器件选型库完成 BOM 物料明细,并通过系统校验后保存。");
|
|
|
|
|
|
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() {
|
|
|
|
|
|
downloadTextFile("智能硬件成本结构图-实训操作指导.txt", [
|
|
|
|
|
|
"智能硬件成本结构图绘制指导",
|
|
|
|
|
|
"1. 分别汇总第 2 步物料 BOM 成本与第 3 步制造成本。",
|
|
|
|
|
|
"2. 小批量图和大批量图分别使用饼图或环形图,清晰标注 BOM、制造成本及主要费用占比。",
|
|
|
|
|
|
"3. 结合占比最高的费用项提出降档、替换、减量或集成方案,并说明预期效果和风险控制措施。",
|
|
|
|
|
|
].join("\n"));
|
|
|
|
|
|
}
|
|
|
|
|
|
async function downloadCostStructureTemplate() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const XLSX = await import("xlsx");
|
|
|
|
|
|
const data = [
|
|
|
|
|
|
["智能硬件成本结构模板"],
|
|
|
|
|
|
[],
|
|
|
|
|
|
["成本类别", "小批量试产(元/台)", "大批量量产(元/台)"],
|
|
|
|
|
|
["物料 BOM 成本", smallBatchTotal.value, largeBatchTotal.value],
|
|
|
|
|
|
["制造成本", smallBatchManufacturingCost.value, largeBatchManufacturingCost.value],
|
|
|
|
|
|
["单件总成本", smallBatchUnitTotalCost.value, largeBatchUnitTotalCost.value],
|
|
|
|
|
|
];
|
|
|
|
|
|
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
|
|
|
|
|
worksheet["!cols"] = [{ wch: 24 }, { wch: 28 }, { wch: 28 }];
|
|
|
|
|
|
const workbook = XLSX.utils.book_new();
|
|
|
|
|
|
XLSX.utils.book_append_sheet(workbook, worksheet, "成本结构");
|
|
|
|
|
|
XLSX.writeFile(workbook, "智能硬件成本结构模板.xlsx");
|
|
|
|
|
|
notifySuccess("Excel 模板已下载");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
notifyError(error?.message || "模板下载失败,请稍后重试");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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:#d9edfb;background:#061522}.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:#6de0ff;font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}.task-badge{padding:7px 10px;border:1px solid rgba(64,204,255,.38);border-radius:8px;background:rgba(11,95,142,.24)}.task-title{margin:0;color:#f2fbff;font-size:30px;line-height:1.2}.step-navigation{display:flex;align-items:center;margin:22px 0 16px;padding:10px 16px;border:1px solid rgba(60,171,221,.28);border-radius:14px;background:rgba(5,33,50,.78)}.step-nav-item{display:inline-flex;align-items:center;gap:9px;border:0;padding:8px 10px;color:#90b6c8;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:11px}.step-nav-item.active{color:#6de0ff}.step-nav-item.active span{color:#032036;border-color:#6de0ff;background:#6de0ff}.step-connector{width:42px;height:1px;margin:0 4px;background:linear-gradient(90deg,#36bfe8,transparent)}.task-card{padding:26px;border:1px solid rgba(60,171,221,.34);border-radius:18px;background:linear-gradient(145deg,rgba(9,39,58,.96),rgba(4,23,37,.98));box-shadow:0 18px 48px rgba(0,0,0,.22)}.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:#f2fbff}.workbench-head h2{font-size:24px}.cost-brief{display:flex;align-items:flex-start;gap:12px;margin-bottom:22px;padding:15px 18px;border:1px solid rgba(60,182,235,.22);border-radius:12px;background:rgba(4,78,113,.18)}.cost-brief__index{display:grid;flex:none;place-items:center;width:28px;height:28px;border-radius:50%;color:#032036;font-size:12px;font-weight:800;background:#67dcff}.cost-brief p{margin:2px 0 0;color:#b6d4e5;line-height:1.75}.step-card,.constraint-panel{padding:22px;border:1px solid rgba(78,181,229,.22);border-radius:14px;background:rgba(2,19,31,.58)}.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:#9ebaca;font-size:13px}.constraint-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.constraint-field{display:grid;gap:8px;color:#dff5ff;font-weight:700}.step-title-row{margin-bottom:16px}.step-title-row h3{font-size:21px}.step-chip{padding:8px 11px;border:1px solid rgba(82,212,255,.4);border-radius:99px;color:#83e4ff;font-size:13px;background:rgba(4,108,158,.2)}.bom-toolbar{align-items:flex-start;margin-bottom:14px}.bom-toolbar p{max-width:760px;margin:0;color:#9ebaca;line-height:1.65}.bom-toolbar__actions{display:flex;flex:none;gap:10px}.btn-nav{border:1px solid rgba(101,213,255,.4);border-radius:8px;padding:9px 15px;color:#dff7ff;font-weight:700;cursor:pointer;background:rgba(16,111,154,.4);transition:.18s ease}.btn-nav:hover:not(:disabled){transform:translateY(-1px);border-color:#76e5ff;background:rgba(18,143,195,.57)}.btn-nav:disabled{cursor:not-allowed;opacity:.46}.btn-primary{border-color:#56d8ff;color:#042033;background:linear-gradient(90deg,#78e7ff,#43c8f3)}.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 rgba(89,165,202,.34);border-radius:10px}.bom-table{width:100%;min-width:980px;border-collapse:collapse;table-layout:fixed}.bom-table th{padding:13px 10px;color:#eefbff;font-weight:800;background:#0d87bb}.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
|
|
|
|
|
|
.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:#a6d8ee;font-size:16px;font-weight:800;background:rgba(8,71,102,.3)!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 rgba(75,185,230,.27);border-radius:13px;background:rgba(3,29,45,.68)}.chart-upload-card h4{margin:0;color:#e9f9ff;font-size:17px}.chart-upload-card p{min-height:40px;margin:7px 0 15px;color:#95b9ca;font-size:13px;line-height:1.65}.chart-upload-trigger{display:flex;align-items:center;justify-content:center;min-height:42px;border:1px dashed rgba(102,221,255,.58);border-radius:8px;color:#c8f5ff;font-weight:800;cursor:pointer;background:rgba(12,112,158,.18);transition:.18s ease}.chart-upload-trigger:hover{border-color:#75e4ff;background:rgba(15,144,195,.31)}.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 rgba(105,190,222,.26);border-radius:9px;background:rgba(2,19,31,.58)}.chart-preview :deep(.el-image){width:104px;height:72px;border-radius:6px;cursor:zoom-in;background:#061827}.chart-preview>div{display:grid;gap:4px;min-width:0}.chart-preview span{overflow:hidden;color:#cbe9f5;font-size:13px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.chart-preview small{color:#8fb4c7;font-size:12px}.chart-preview button{border:0;color:#ffc6ce;font-size:13px;cursor:pointer;background:transparent}.decision-hint{margin:8px 0 0;color:#91b8ca;font-size:13px}.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:16px}.reference-downloads .btn-nav:hover{color:#e4faff}
|
|
|
|
|
|
@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}}
|
|
|
|
|
|
</style>
|