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