style: 统一代码缩进与格式,修复模板头部空格问题

本次提交调整了多处文件的代码格式:
1. 修复了两个vue页面模板开头的多余空格问题
2. 统一了导航组件的缩进风格,修正了菜单列表的换行格式
3. 统一了scoped样式块的缩进与换行规范,提升代码可读性
fix/exam-question-access-material-excel-delete-draft
qinzhenpen 3 weeks ago
parent 1804e3fa58
commit 569839be27

@ -1,4 +1,4 @@
<template>
<template>
<div class="channel student-training-shell">
<main class="training-core">
<section class="task-header">

@ -12,40 +12,34 @@
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<ul>
<li v-for="goal in taskGoals" :key="goal">{{ goal }}</li>
</ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
<div class="workbench-actions">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
<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"
/>
<button class="action-button" type="button" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
:guide-manual-name="taskConfig?.guideManualName" :guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName" :ai-manual-url="taskConfig?.aiManualUrl" />
<button class="action-button" type="button" @click="exportOutcome"><el-icon>
<Download />
</el-icon>导出实训成果</button>
</div>
</section>
<section class="workbench">
<nav class="step-indicator" aria-label="商业可行性论证步骤">
<button
v-for="(step, index) in trainingSteps"
:key="step"
type="button"
<button v-for="(step, index) in trainingSteps" :key="step" type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]"
:aria-label="`第 ${index + 1} 步:${step}`"
:aria-current="currentStep === index + 1 ? 'step' : undefined"
:title="step"
@click="switchStep(index + 1)"
>
:aria-current="currentStep === index + 1 ? 'step' : undefined" :title="step"
@click="switchStep(index + 1)">
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-name">{{ step }}</span>
@ -53,127 +47,367 @@
</nav>
<section v-if="currentStep === 1" class="step-card">
<div class="step-heading"><div><p class="section-eyebrow">Step 01</p><h2>商业可行性论证</h2></div><em>结构化论证</em></div>
<div class="step-heading">
<div>
<p class="section-eyebrow">Step 01</p>
<h2>商业可行性论证</h2>
</div><em>结构化论证</em>
</div>
<p class="intro">商业可行性论证需要从多个维度系统展开。以下提供了可行性分析的各个子模块,根据案例信息,完善填写以下信息。</p>
<section class="form-section">
<div class="section-title"><span>01</span><div><h3>项目概述</h3><p>结合前序 PEST、SWOT 分析,说明项目为何值得推进。</p></div></div>
<div class="section-title"><span>01</span>
<div>
<h3>项目概述</h3>
<p>结合前序 PEST、SWOT 分析,说明项目为何值得推进。</p>
</div>
</div>
<div class="form-table overview-table">
<label v-for="field in overviewFields" :key="field.key"><span>{{ field.label }}</span><el-input v-model="form.projectOverview[field.key]" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" :placeholder="field.placeholder" /></label>
<label v-for="field in overviewFields" :key="field.key"><span>{{ field.label
}}</span><el-input v-model="form.projectOverview[field.key]" type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }" :placeholder="field.placeholder" /></label>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>02</span><div><h3>市场规模与竞争格局</h3><p>填写可验证的市场数据,比例项目以百分数填写,例如 12.5。</p></div></div>
<div class="section-title"><span>02</span>
<div>
<h3>市场规模与竞争格局</h3>
<p>填写可验证的市场数据,比例项目以百分数填写,例如 12.5。</p>
</div>
</div>
<div class="form-table market-table">
<label v-for="field in marketFields" :key="field.key"><span>{{ field.label }}</span><div class="input-with-suffix"><el-input-number v-if="field.type === 'number'" v-model="form.marketAnalysis[field.key]" :min="0" :step="0.01" controls-position="right" :placeholder="field.placeholder" /><el-input v-else v-model="form.marketAnalysis[field.key]" :placeholder="field.placeholder" /><b v-if="field.suffix">{{ field.suffix }}</b></div></label>
<label v-for="field in marketFields" :key="field.key"><span>{{ field.label }}</span>
<div class="input-with-suffix"><el-input-number v-if="field.type === 'number'"
v-model="form.marketAnalysis[field.key]" :min="0" :step="0.01"
controls-position="right" :placeholder="field.placeholder" /><el-input v-else
v-model="form.marketAnalysis[field.key]" :placeholder="field.placeholder" /><b
v-if="field.suffix">{{ field.suffix }}</b></div>
</label>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>03</span><div><h3>目标用户群体分析</h3><p>从不同消费能力和使用场景中识别最有价值的用户。</p></div></div>
<div class="table-wrap"><table><thead><tr><th>用户群体</th><th>典型场景</th><th>核心需求</th><th>消费能力</th></tr></thead><tbody><tr v-for="(user, index) in form.targetUsers" :key="index"><td><el-input v-model="user.userGroup" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" :placeholder="targetUserExamples[index].userGroup" /></td><td><el-input v-model="user.scenario" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" :placeholder="targetUserExamples[index].scenario" /></td><td><el-input v-model="user.coreNeed" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" :placeholder="targetUserExamples[index].coreNeed" /></td><td><el-input v-model="user.spendingPower" :placeholder="targetUserExamples[index].spendingPower" /></td></tr></tbody></table></div>
<div class="section-title"><span>03</span>
<div>
<h3>目标用户群体分析</h3>
<p>从不同消费能力和使用场景中识别最有价值的用户。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>用户群体</th>
<th>典型场景</th>
<th>核心需求</th>
<th>消费能力</th>
</tr>
</thead>
<tbody>
<tr v-for="(user, index) in form.targetUsers" :key="index">
<td><el-input v-model="user.userGroup" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="targetUserExamples[index].userGroup" /></td>
<td><el-input v-model="user.scenario" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="targetUserExamples[index].scenario" /></td>
<td><el-input v-model="user.coreNeed" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="targetUserExamples[index].coreNeed" /></td>
<td><el-input v-model="user.spendingPower"
:placeholder="targetUserExamples[index].spendingPower" /></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>04</span><div><h3>商业模式梳理</h3><p>分别说明基础销售和持续增值服务如何形成收入。</p></div></div>
<div class="table-wrap"><table><thead><tr><th>模块</th><th>案例公司的策略</th><th>说明</th></tr></thead><tbody><tr v-for="model in form.businessModels" :key="model.module"><td class="fixed-cell">{{ model.module }}</td><td><el-input v-model="model.strategy" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写渠道、产品组合或服务策略" /></td><td><el-input v-model="model.explanation" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="说明策略与用户、成本的关系" /></td></tr></tbody></table></div>
<div class="section-title"><span>04</span>
<div>
<h3>商业模式梳理</h3>
<p>分别说明基础销售和持续增值服务如何形成收入。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>模块</th>
<th>案例公司的策略</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="model in form.businessModels" :key="model.module">
<td class="fixed-cell">{{ model.module }}</td>
<td><el-input v-model="model.strategy" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写渠道、产品组合或服务策略" />
</td>
<td><el-input v-model="model.explanation" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" placeholder="说明策略与用户、成本的关系" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>05</span><div><h3>成本结构分析</h3><p>填写各项单位成本,单位总成本由系统自动汇总。</p></div></div>
<div class="table-wrap"><table><thead><tr><th>成本项目</th><th>金额(元/台)</th></tr></thead><tbody><tr v-for="cost in form.costs" :key="cost.id"><td class="fixed-cell">{{ cost.label }}</td><td><el-input-number v-model="cost.amount" :min="0" :step="0.01" controls-position="right" placeholder="填写金额" /></td></tr><tr class="total-row"><td>单位总成本</td><td><span class="computed-value">{{ currency(totalCost) }}</span></td></tr></tbody></table></div>
<div class="section-title"><span>05</span>
<div>
<h3>成本结构分析</h3>
<p>填写各项单位成本,单位总成本由系统自动汇总。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>成本项目</th>
<th>金额(元/台)</th>
</tr>
</thead>
<tbody>
<tr v-for="cost in form.costs" :key="cost.id">
<td class="fixed-cell">{{ cost.label }}</td>
<td><el-input-number v-model="cost.amount" :min="0" :step="0.01"
controls-position="right" placeholder="填写金额" /></td>
</tr>
<tr class="total-row">
<td>单位总成本</td>
<td><span class="computed-value">{{ currency(totalCost) }}</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>06</span><div><h3>定价与毛利分析</h3><p>单位毛利 = 单位目标售价 − 单位总成本;毛利率 = 单位毛利 ÷ 单位总成本。</p></div></div>
<div class="table-wrap"><table><thead><tr><th>指标</th><th>数值</th></tr></thead><tbody><tr><td class="fixed-cell">目标售价(元/台)</td><td><el-input-number v-model="form.targetPrice" :min="0" :step="0.01" controls-position="right" placeholder="填写目标售价" /></td></tr><tr class="total-row"><td>单位毛利(元/台)</td><td><span class="computed-value">{{ currency(unitGrossProfit) }}</span></td></tr><tr class="total-row"><td>毛利率(%)</td><td><span class="computed-value">{{ percent(grossMargin) }}</span></td></tr></tbody></table></div>
<div class="section-title"><span>06</span>
<div>
<h3>定价与毛利分析</h3>
<p>单位毛利 = 单位目标售价 − 单位总成本;毛利率 = 单位毛利 ÷ 单位总成本。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>指标</th>
<th>数值</th>
</tr>
</thead>
<tbody>
<tr>
<td class="fixed-cell">目标售价(元/台)</td>
<td><el-input-number v-model="form.targetPrice" :min="0" :step="0.01"
controls-position="right" placeholder="填写目标售价" /></td>
</tr>
<tr class="total-row">
<td>单位毛利(元/台)</td>
<td><span class="computed-value">{{ currency(unitGrossProfit) }}</span></td>
</tr>
<tr class="total-row">
<td>毛利率(%)</td>
<td><span class="computed-value">{{ percent(grossMargin) }}</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="form-section">
<div class="section-title"><span>07</span><div><h3>风险识别与评估</h3><p>识别项目主要风险,并明确影响程度及可执行的应对措施。</p></div></div>
<div class="table-wrap"><table><thead><tr><th>风险类型</th><th>具体风险描述</th><th>影响程度</th><th>应对措施</th></tr></thead><tbody><tr v-for="risk in form.risks" :key="risk.type"><td class="fixed-cell">{{ risk.type }}</td><td><el-input v-model="risk.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写具体风险" /></td><td><el-select v-model="risk.impact" placeholder="请选择" size="default" class="risk-impact-select"><el-option label="高" value="高" /><el-option label="中" value="中" /><el-option label="低" value="低" /></el-select></td><td><el-input v-model="risk.response" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写应对措施" /></td></tr></tbody></table></div>
<div class="section-title"><span>07</span>
<div>
<h3>风险识别与评估</h3>
<p>识别项目主要风险,并明确影响程度及可执行的应对措施。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>风险类型</th>
<th>具体风险描述</th>
<th>影响程度</th>
<th>应对措施</th>
</tr>
</thead>
<tbody>
<tr v-for="risk in form.risks" :key="risk.type">
<td class="fixed-cell">{{ risk.type }}</td>
<td><el-input v-model="risk.description" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写具体风险" /></td>
<td><el-select v-model="risk.impact" placeholder="请选择" size="default"
class="risk-impact-select"><el-option label="高" value="高" /><el-option
label="中" value="中" /><el-option label="低" value="低" /></el-select>
</td>
<td><el-input v-model="risk.response" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" placeholder="填写应对措施" /></td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="step-actions"><button class="action-button primary" type="button" :disabled="saving" @click="validateAndContinue">{{ saving ? '校验并保存中…' : '校验并进入第 2 步' }}</button><button class="action-button" type="button" :disabled="saving" @click="resetForm"><el-icon><RefreshLeft /></el-icon>清空填写</button></div>
<div class="step-actions"><button class="action-button primary" type="button" :disabled="saving"
@click="validateAndContinue">{{ saving ? '校验并保存中…' : '校验并进入第 2 步' }}</button><button
class="action-button" type="button" :disabled="saving" @click="resetForm"><el-icon>
<RefreshLeft />
</el-icon>清空填写</button></div>
</section>
<section v-else-if="currentStep === 2" class="step-card">
<div class="step-heading"><div><p class="section-eyebrow">Step 02</p><h2>项目可行性初步决策</h2></div><em>立项判断</em></div>
<div class="step-heading">
<div>
<p class="section-eyebrow">Step 02</p>
<h2>项目可行性初步决策</h2>
</div><em>立项判断</em>
</div>
<p class="intro">综合以上所有分析,初步判断项目的可行性,并明确如果立项后最需要优先解决的关键问题。</p>
<section class="decision-section">
<h3><span>01</span>该项目商业可行性总体评估为:</h3>
<el-radio-group v-model="stepTwo.decision" class="decision-grid" @change="handleDecisionChange">
<el-radio v-for="option in feasibilityOptions" :key="option.value" :label="option.value" class="decision-option">
<el-radio v-for="option in feasibilityOptions" :key="option.value" :label="option.value"
class="decision-option">
<strong>{{ option.label }}</strong><small>{{ option.hint }}</small>
</el-radio>
</el-radio-group>
</section>
<section class="decision-section priority-section" :class="{ muted: stepTwo.decision === 'not-feasible' }">
<div class="priority-heading"><h3><span>02</span>如果立项,你认为最需要优先解决的 3 个关键问题是:</h3><b>{{ selectedPriorityCount }}/3</b></div>
<section class="decision-section priority-section"
:class="{ muted: stepTwo.decision === 'not-feasible' }">
<div class="priority-heading">
<h3><span>02</span>如果立项,你认为最需要优先解决的 3 个关键问题是:</h3><b>{{ selectedPriorityCount }}/3</b>
</div>
<p v-if="stepTwo.decision === 'not-feasible'" class="hint">已选择"不建议立项",本项无需填写。</p>
<p v-else class="hint">请选择且仅选择 3 项,作为后续立项推进的优先事项。</p>
<el-checkbox-group v-model="stepTwo.priorities" class="priority-grid">
<el-checkbox v-for="item in priorityOptions" :key="item.value" :label="item.value" :disabled="stepTwo.decision === 'not-feasible' || (!stepTwo.priorities.includes(item.value) && selectedPriorityCount >= 3)" class="priority-option">
<el-checkbox v-for="item in priorityOptions" :key="item.value" :label="item.value"
:disabled="stepTwo.decision === 'not-feasible' || (!stepTwo.priorities.includes(item.value) && selectedPriorityCount >= 3)"
class="priority-option">
<span><strong>{{ item.label }}</strong><small>{{ item.detail }}</small></span>
</el-checkbox>
</el-checkbox-group>
</section>
<div class="step-actions"><button class="action-button primary" type="button" :disabled="saving" @click="saveStepTwo">{{ saving ? '校验并保存中…' : '保存并进入第 3 步' }}</button><button class="action-button" type="button" :disabled="saving" @click="resetStepTwo"><el-icon><RefreshLeft /></el-icon>清空填写</button></div>
<div class="step-actions"><button class="action-button primary" type="button" :disabled="saving"
@click="saveStepTwo">{{ saving ? '校验并保存中…' : '保存并进入第 3 步' }}</button><button
class="action-button" type="button" :disabled="saving" @click="resetStepTwo"><el-icon>
<RefreshLeft />
</el-icon>清空填写</button></div>
</section>
<section v-else-if="currentStep === 3" class="step-card report-step">
<div class="step-heading"><div><p class="section-eyebrow">Step 03</p><h2>可行性论证报告</h2></div><em>成果提交</em></div>
<div class="report-guide"><p>根据前两步的论证结构与信息,结合实训 4–6,撰写可行性论证报告。可参考以下方法:</p><ol><li>根据报告模板,自行分析撰写可行性论证报告。</li><li>使用通用 AI 大模型辅助完成报告,参考步骤提示词见案例文档。</li></ol></div>
<div class="step-heading">
<div>
<p class="section-eyebrow">Step 03</p>
<h2>可行性论证报告</h2>
</div><em>成果提交</em>
</div>
<div class="report-guide">
<p>根据前两步的论证结构与信息,结合实训 4–6,撰写可行性论证报告。可参考以下方法:</p>
<ol>
<li>根据报告模板,自行分析撰写可行性论证报告。</li>
<li>使用通用 AI 大模型辅助完成报告,参考步骤提示词见案例文档。</li>
</ol>
</div>
<section class="report-upload-panel">
<input ref="reportInput" class="visually-hidden" type="file" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" @change="handleReportUpload" />
<template v-if="!report.url"><el-icon class="upload-orbit"><Document /></el-icon><h3>上传可行性论证报告</h3><p>支持 PDF、Word、PNG、JPG,文件不超过 20MB。</p><button class="action-button primary" type="button" :disabled="uploading" @click="openReportPicker"><el-icon><UploadFilled /></el-icon>{{ uploading ? '上传中…' : '选择报告附件' }}</button></template>
<template v-else><div class="report-file-card"><div class="report-file-icon"><el-icon><Document /></el-icon></div><div class="report-file-meta"><strong>{{ report.name || '可行性论证报告' }}</strong><span>{{ reportKindLabel }} · {{ readableSize(report.size) }}</span></div><div class="report-file-actions"><button type="button" @click="openReportPreview"><el-icon><View /></el-icon>预览</button><button type="button" @click="downloadReport"><el-icon><Download /></el-icon>下载</button><button type="button" class="danger" @click="clearReport"><el-icon><Delete /></el-icon>移除</button></div></div><div class="report-replace"><button class="text-action" type="button" :disabled="uploading" @click="openReportPicker"><el-icon><UploadFilled /></el-icon>{{ uploading ? '上传中…' : '更换附件' }}</button><span>附件已上传,可随时预览或下载核对。</span></div></template>
<input ref="reportInput" class="visually-hidden" type="file"
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" @change="handleReportUpload" />
<template v-if="!report.url"><el-icon class="upload-orbit">
<Document />
</el-icon>
<h3>上传可行性论证报告</h3>
<p>支持 PDF、Word、PNG、JPG,文件不超过 20MB。</p><button class="action-button primary" type="button"
:disabled="uploading" @click="openReportPicker"><el-icon>
<UploadFilled />
</el-icon>{{ uploading ? '上传中…' : '选择报告附件' }}</button>
</template>
<template v-else>
<div class="report-file-card">
<div class="report-file-icon"><el-icon>
<Document />
</el-icon></div>
<div class="report-file-meta"><strong>{{ report.name || '可行性论证报告' }}</strong><span>{{
reportKindLabel }} · {{ readableSize(report.size) }}</span></div>
<div class="report-file-actions"><button type="button"
@click="openReportPreview"><el-icon>
<View />
</el-icon>预览</button><button type="button" @click="downloadReport"><el-icon>
<Download />
</el-icon>下载</button><button type="button" class="danger"
@click="clearReport"><el-icon>
<Delete />
</el-icon>移除</button></div>
</div>
<div class="report-replace"><button class="text-action" type="button" :disabled="uploading"
@click="openReportPicker"><el-icon>
<UploadFilled />
</el-icon>{{ uploading ? '上传中…' : '更换附件' }}</button><span>附件已上传,可随时预览或下载核对。</span>
</div>
</template>
</section>
<div class="step-actions"><button class="action-button primary" type="button" :disabled="saving || uploading" @click="saveStepThree">{{ saving ? '校验并保存中…' : '保存并进入第 4 步' }}</button></div>
<el-dialog v-model="reportPreviewVisible" class="report-preview-dialog" :title="report.name || '可行性论证报告预览'" width="min(1100px, 94vw)" destroy-on-close append-to-body><div class="report-preview"><el-image v-if="reportKind === 'image'" :src="report.url" fit="contain" :preview-src-list="[report.url]" preview-teleported /><iframe v-else :src="reportPreviewUrl" title="可行性论证报告预览"></iframe></div><template #footer><button class="action-button" type="button" @click="downloadReport"><el-icon><Download /></el-icon>下载附件</button></template></el-dialog>
<div class="step-actions"><button class="action-button primary" type="button"
:disabled="saving || uploading" @click="saveStepThree">{{ saving ? '校验并保存中…' : '保存并进入第 4 步'
}}</button></div>
<el-dialog v-model="reportPreviewVisible" class="report-preview-dialog"
:title="report.name || '可行性论证报告预览'" width="min(1100px, 94vw)" destroy-on-close append-to-body>
<div class="report-preview"><el-image v-if="reportKind === 'image'" :src="report.url"
fit="contain" :preview-src-list="[report.url]" preview-teleported /><iframe v-else
:src="reportPreviewUrl" title="可行性论证报告预览"></iframe></div><template #footer><button
class="action-button" type="button" @click="downloadReport"><el-icon>
<Download />
</el-icon>下载附件</button></template>
</el-dialog>
</section>
<section v-else class="step-card placeholder">
<p>第 {{ currentStep }} 步将在后续实训页面中配置。</p><button class="action-button" type="button"
@click="currentStep = 3">返回第 3
步</button>
</section>
<section v-else class="step-card placeholder"><p>第 {{ currentStep }} 步将在后续实训页面中配置。</p><button class="action-button" type="button" @click="currentStep = 3">返回第 3 步</button></section>
</section>
</main>
<TrainingAiSidebar study-tip="先从案例资料中提炼可验证的市场、用户与成本依据,再形成一致的商业论证。" reference-title="可行性论证提示" reference-text="• 结论要有数据或前序分析支撑<br />• 价格、成本和毛利需要逻辑一致<br />• 风险应附带可执行的应对措施" :progress-items="progressItems" />
<TrainingAiSidebar study-tip="先从案例资料中提炼可验证的市场、用户与成本依据,再形成一致的商业论证。" reference-title="可行性论证提示"
reference-text="• 结论要有数据或前序分析支撑<br />• 价格、成本和毛利需要逻辑一致<br />• 风险应附带可执行的应对措施"
:progress-items="progressItems" />
</div>
</template>
<script setup>
import { Check, Delete, Document, Download, RefreshLeft, UploadFilled, View } from "@element-plus/icons-vue";
import { checkBusinessFeasibilityStepOne, checkBusinessFeasibilityStepTwo, checkBusinessFeasibilityStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
const TASK_KEY = "business-feasibility";
const DEFAULT_STEPS = ["商业可行性论证", "项目初步决策", "可行性论证报告", "决策结论"];
import { Check, Delete, Document, Download, RefreshLeft, UploadFilled, View } from "@element-plus/icons-vue"
import { checkBusinessFeasibilityStepOne, checkBusinessFeasibilityStepTwo, checkBusinessFeasibilityStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"
const TASK_KEY = "business-feasibility"
const DEFAULT_STEPS = ["商业可行性论证", "项目初步决策", "可行性论证报告", "决策结论"]
const overviewFields = [
{ key: "projectBackground", label: "项目背景", placeholder: "示例:根据案例信息简述公司基本情况,再简要阐述智能家居、个护健康、美妆工具等相关市场发展情况和需求情况。" },
{ key: "productPositioning", label: "产品定位", placeholder: "示例:阐述项目产品针对的目标市场用户、核心卖点、使用场景、差异化理由以及用户收益。" },
{ key: "projectGoals", label: "项目目标", placeholder: "示例:填写 12 个月短期目标、24 个月中期目标和 36 个月长期目标。" },
{ key: "macroEnvironmentConclusion", label: "宏观环境分析结论", placeholder: "示例:项目产品 PEST 分析结论。" },
{ key: "competitiveLandscapeConclusion", label: "竞争态势分析结论", placeholder: "示例:项目产品 SWOT 分析结论。" },
];
]
const marketFields = [
{ key: "annualMarketSize", label: "年度市场规模", type: "number", suffix: "亿元", placeholder: "例如 80" },
{ key: "monthlyGrowthRate", label: "月均增长率", type: "number", suffix: "%", placeholder: "例如 2.5" },
{ key: "topBrandMarketShare", label: "头部第一品牌市场份额", type: "number", suffix: "%", placeholder: "例如 26" },
{ key: "competitorsPriceRange", label: "主要竞品售价区间", suffix: "元", placeholder: "例如 299 - 899" },
{ key: "marketConcentrationCr3", label: "市场集中度(CR3)", type: "number", suffix: "%", placeholder: "例如 58" },
];
]
const targetUserExamples = [
{ userGroup: "示例:18-35 岁一二线城市美妆爱好者、美妆博主", scenario: "示例:美妆产品带货、线上美妆教程", coreNeed: "示例:补光效果、AI 测肤精度", spendingPower: "示例:高" },
{ userGroup: "示例:22-40 岁租房白领、早八通勤族", scenario: "示例:早上上班前快速护肤化妆", coreNeed: "示例:日常化妆、耐用", spendingPower: "示例:中" },
{ userGroup: "示例:18-28 岁学生群体、礼品采购人群", scenario: "示例:宿舍美妆尝试、礼物赠送", coreNeed: "示例:宿舍美妆工具、生日礼物", spendingPower: "示例:低" },
];
]
const feasibilityOptions = [
{ value: "highly-feasible", label: "高度可行,建议立即立项启动", hint: "市场、收益与风险条件均具备" },
{ value: "feasible-after-optimization", label: "基本可行,建议在优化部分条件后立项", hint: "关键条件完善后再推进" },
{ value: "observe", label: "可行性一般,建议暂缓立项继续观察", hint: "保留机会并持续验证" },
{ value: "not-feasible", label: "不可行,建议放弃该方向", hint: "当前条件不支持投入" },
];
]
const priorityOptions = [
{ value: "hardware-cost-control", label: "硬件成本控制", detail: "降低 BOM 成本" },
{ value: "content-ecosystem", label: "内容生态搭建", detail: "相关教程课程合作" },
@ -181,44 +415,44 @@ const priorityOptions = [
{ value: "brand-differentiation", label: "品牌差异化", detail: "与头部品牌形成区隔" },
{ value: "supply-chain-management", label: "供应链管理", detail: "产能保障与品质控制" },
{ value: "capital-planning", label: "资金筹措", detail: "融资或分期投入" },
];
const defaultTask = { title: "商业可行性论证", background: "在产品开发前,综合市场机会、用户需求、竞争态势、成本收益和风险,判断项目是否具有可推进的商业价值。", goals: ["掌握商业可行性论证的基本维度", "形成市场、用户、成本与收益的一致判断", "识别主要风险并提出应对措施"], requirement: "请以案例资料和前序分析为依据,完成表格全部内容;成本与价格数据需真实、合理且逻辑一致。" };
const { proxy } = getCurrentInstance();
const briefOpen = ref(false);
const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const completed = ref(false); const stepTwoCompleted = ref(false); const stepThreeCompleted = ref(false); const reportInput = ref(null); const reportPreviewVisible = ref(false); const report = ref(createReport()); const form = ref(createForm()); const stepTwo = ref(createStepTwo());
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS));
const totalCost = computed(() => form.value.costs.reduce((sum, cost) => sum + numeric(cost.amount), 0)); const unitGrossProfit = computed(() => numeric(form.value.targetPrice) - totalCost.value); const grossMargin = computed(() => totalCost.value > 0 ? unitGrossProfit.value / totalCost.value * 100 : null);
const progressItems = computed(() => [{ text: completed.value ? "项目论证已保存" : "待完成项目论证", status: completed.value ? "done" : "" }, { text: totalCost.value > 0 ? "成本与毛利已计算" : "待填写成本与售价", status: totalCost.value > 0 ? "done" : "" }, { text: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "风险评估已完成" : "待完成风险评估", status: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "done" : "" }]);
const selectedPriorityCount = computed(() => stepTwo.value.priorities.length);
const reportKind = computed(() => resolveReportKind(report.value)); const reportKindLabel = computed(() => ({ image: "图片附件", pdf: "PDF 文档", word: "Word 文档" }[reportKind.value] || "报告附件")); const reportPreviewUrl = computed(() => { if (reportKind.value !== "word") return report.value.url; const absoluteUrl = /^https?:\/\//i.test(report.value.url) ? report.value.url : new URL(report.value.url, window.location.origin).href; return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(absoluteUrl)}`; });
onMounted(async () => { await loadTaskConfig(); await loadSavedAnswer(); });
function createForm() { return { projectOverview: { projectBackground: "", productPositioning: "", projectGoals: "", macroEnvironmentConclusion: "", competitiveLandscapeConclusion: "" }, marketAnalysis: { annualMarketSize: "", monthlyGrowthRate: "", topBrandMarketShare: "", competitorsPriceRange: "", marketConcentrationCr3: "" }, targetUsers: Array.from({ length: 3 }, () => ({ userGroup: "", scenario: "", coreNeed: "", spendingPower: "" })), businessModels: [{ module: "产品销售渠道", strategy: "", explanation: "" }, { module: "增值服务", strategy: "", explanation: "" }], costs: [{ id: "hardware-bom", label: "硬件 BOM 成本", amount: "" }, { id: "manufacturing", label: "生产制造费用", amount: "" }, { id: "rd-amortization", label: "研发费用摊销", amount: "" }, { id: "channel-marketing", label: "渠道与营销费用", amount: "" }, { id: "logistics-warehouse", label: "物流仓储费用", amount: "" }], targetPrice: "", risks: ["技术风险", "市场风险", "供应链风险", "资金风险"].map((type) => ({ type, description: "", impact: "", response: "" })) }; }
function createStepTwo() { return { decision: "", priorities: [] }; }
function createReport() { return { url: "", name: "", size: 0 }; }
function numeric(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; }
function currency(value) { return Number(value || 0).toFixed(2); }
function percent(value) { return value === null || !Number.isFinite(value) ? "--" : `${Number(value).toFixed(2)}%`; }
function parseArray(value, fallback) { if (Array.isArray(value) && value.length) return value; if (!value) return fallback; try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback; } catch (_) { const items = String(value).split(/[,,|]/).map((item) => item.trim()).filter(Boolean); return items.length ? items : fallback; } }
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (_) { taskConfig.value = null; } }
async function loadSavedAnswer() { try { const saved = await getStudentTrainingAnswer(TASK_KEY)?.data; if (!saved) return; if (saved.step1Answer) { const parsed = JSON.parse(saved.step1Answer); form.value = mergeForm(parsed?.form || parsed); completed.value = true; } if (saved.step2Answer) { const parsed = JSON.parse(saved.step2Answer); stepTwo.value = { ...createStepTwo(), ...(parsed?.stepTwo || parsed || {}), priorities: Array.isArray(parsed?.stepTwo?.priorities || parsed?.priorities) ? (parsed?.stepTwo?.priorities || parsed?.priorities) : [] }; stepTwoCompleted.value = true; } if (saved.step3Answer) { const parsed = JSON.parse(saved.step3Answer); report.value = { ...createReport(), ...(parsed?.report || parsed || {}) }; stepThreeCompleted.value = Boolean(report.value.url); } if (Number(saved.currentStep) > 0) currentStep.value = Number(saved.currentStep); } catch (_) { /* keep a new answer editable */ } }
function mergeForm(value) { const fallback = createForm(); const merged = { ...fallback, ...(value || {}), projectOverview: { ...fallback.projectOverview, ...(value?.projectOverview || {}) }, marketAnalysis: { ...fallback.marketAnalysis, ...(value?.marketAnalysis || {}) }, targetUsers: Array.isArray(value?.targetUsers) && value.targetUsers.length === 3 ? value.targetUsers : fallback.targetUsers, businessModels: Array.isArray(value?.businessModels) && value.businessModels.length === 2 ? value.businessModels : fallback.businessModels, costs: fallback.costs.map((cost) => ({ ...cost, ...(value?.costs || []).find((item) => item?.id === cost.id) })), risks: fallback.risks.map((risk) => ({ ...risk, ...(value?.risks || []).find((item) => item?.type === risk.type) })) }; return merged; }
function payload() { return { ...form.value, costs: form.value.costs.map(({ id, amount }) => ({ id, amount: amount === "" ? null : Number(amount) })), targetPrice: form.value.targetPrice === "" ? null : Number(form.value.targetPrice) }; }
async function validateAndContinue() { if (saving.value) return; saving.value = true; try { const result = await checkBusinessFeasibilityStepOne(payload()); const calculated = result?.data || {}; form.value.costs = form.value.costs.map((cost) => ({ ...cost, amount: cost.amount === "" ? "" : Number(cost.amount) })); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify({ form: form.value, calculations: { unitTotalCost: calculated.unitTotalCost ?? totalCost.value, unitGrossProfit: calculated.unitGrossProfit ?? unitGrossProfit.value, grossMargin: calculated.grossMargin ?? grossMargin.value } }), currentStep: 2, submitted: false, saveAction: "SAVE" }); completed.value = true; currentStep.value = 2; proxy?.$modal?.msgSuccess("已完成校验并保存,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写本步骤内容后再提交"); } finally { saving.value = false; } }
function resetForm() { form.value = createForm(); completed.value = false; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
async function saveStepTwo() { if (saving.value) return; saving.value = true; try { await checkBusinessFeasibilityStepTwo({ ...stepTwo.value }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify({ stepTwo: stepTwo.value }), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoCompleted.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("已完成校验并保存,已进入第 3 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请选择总体评估,并按要求填写优先问题"); } finally { saving.value = false; } }
function resetStepTwo() { stepTwo.value = createStepTwo(); stepTwoCompleted.value = false; proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function handleDecisionChange() { if (stepTwo.value.decision === "not-feasible") stepTwo.value.priorities = []; }
function openReportPicker() { reportInput.value?.click(); }
async function handleReportUpload(event) { const file = event?.target?.files?.[0]; if (event?.target) event.target.value = ""; if (!file) return; const extension = file.name.split(".").pop()?.toLowerCase(); if (!["pdf", "doc", "docx", "png", "jpg", "jpeg"].includes(extension)) { proxy?.$modal?.msgError("仅支持 PDF、Word、PNG、JPG 格式的报告附件"); return; } if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgError("报告附件不能超过 20MB"); return; } uploading.value = true; try { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); const url = response?.url || response?.data?.url || ""; if (!url) throw new Error("上传未返回文件地址,请重新选择报告附件"); report.value = { url, name: file.name, size: file.size }; proxy?.$modal?.msgSuccess("报告附件上传成功,可点击预览核对"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "报告附件上传失败,请重试"); } finally { uploading.value = false; } }
function resolveReportKind(file) { const name = `${file?.name || ""} ${file?.url || ""}`.toLowerCase(); if (/\.(png|jpe?g)(?:\?|$)/.test(name)) return "image"; if (/\.pdf(?:\?|$)/.test(name)) return "pdf"; return "word"; }
function readableSize(size) { const value = Number(size || 0); return value > 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : value > 1024 ? `${(value / 1024).toFixed(1)} KB` : "附件已上传"; }
function openReportPreview() { if (report.value.url) reportPreviewVisible.value = true; }
function downloadReport() { if (!report.value.url) return; const link = document.createElement("a"); link.href = report.value.url; link.target = "_blank"; link.rel = "noopener"; link.download = report.value.name || "可行性论证报告"; document.body.appendChild(link); link.click(); document.body.removeChild(link); }
function clearReport() { report.value = createReport(); reportPreviewVisible.value = false; stepThreeCompleted.value = false; }
async function saveStepThree() { if (saving.value) return; saving.value = true; try { await checkBusinessFeasibilityStepThree({ reportUrl: report.value.url, reportName: report.value.name }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify({ report: report.value }), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreeCompleted.value = true; currentStep.value = 4; proxy?.$modal?.msgSuccess("报告已保存,已进入第 4 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请先上传可行性论证报告"); } finally { saving.value = false; } }
function switchStep(step) { if (step > 1 && !completed.value) { proxy?.$modal?.msgWarning("请先完成并保存第 1 步"); return; } if (step > 2 && !stepTwoCompleted.value) { proxy?.$modal?.msgWarning("请先完成并保存第 2 步"); return; } if (step > 3 && !stepThreeCompleted.value) { proxy?.$modal?.msgWarning("请先上传并保存第 3 步报告"); return; } currentStep.value = step; }
function exportOutcome() { const blob = new Blob([JSON.stringify({ task: taskTitle.value, step: 1, ...payload(), calculations: { unitTotalCost: totalCost.value, unitGrossProfit: unitGrossProfit.value, grossMargin: grossMargin.value } }, null, 2)], { type: "application/json;charset=utf-8" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "商业可行性论证-第1步.json"; link.click(); URL.revokeObjectURL(link.href); }
]
const defaultTask = { title: "商业可行性论证", background: "在产品开发前,综合市场机会、用户需求、竞争态势、成本收益和风险,判断项目是否具有可推进的商业价值。", goals: ["掌握商业可行性论证的基本维度", "形成市场、用户、成本与收益的一致判断", "识别主要风险并提出应对措施"], requirement: "请以案例资料和前序分析为依据,完成表格全部内容;成本与价格数据需真实、合理且逻辑一致。" }
const { proxy } = getCurrentInstance()
const briefOpen = ref(false)
const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const completed = ref(false); const stepTwoCompleted = ref(false); const stepThreeCompleted = ref(false); const reportInput = ref(null); const reportPreviewVisible = ref(false); const report = ref(createReport()); const form = ref(createForm()); const stepTwo = ref(createStepTwo())
const taskTitle = computed(() => taskConfig.value?.taskName || defaultTask.title); const taskBackground = computed(() => taskConfig.value?.background || defaultTask.background); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultTask.goals)); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultTask.requirement); const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS))
const totalCost = computed(() => form.value.costs.reduce((sum, cost) => sum + numeric(cost.amount), 0)); const unitGrossProfit = computed(() => numeric(form.value.targetPrice) - totalCost.value); const grossMargin = computed(() => totalCost.value > 0 ? unitGrossProfit.value / totalCost.value * 100 : null)
const progressItems = computed(() => [{ text: completed.value ? "项目论证已保存" : "待完成项目论证", status: completed.value ? "done" : "" }, { text: totalCost.value > 0 ? "成本与毛利已计算" : "待填写成本与售价", status: totalCost.value > 0 ? "done" : "" }, { text: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "风险评估已完成" : "待完成风险评估", status: form.value.risks.filter((risk) => risk.description && risk.response && risk.impact).length === 4 ? "done" : "" }])
const selectedPriorityCount = computed(() => stepTwo.value.priorities.length)
const reportKind = computed(() => resolveReportKind(report.value)); const reportKindLabel = computed(() => ({ image: "图片附件", pdf: "PDF 文档", word: "Word 文档" }[reportKind.value] || "报告附件")); const reportPreviewUrl = computed(() => { if (reportKind.value !== "word") return report.value.url; const absoluteUrl = /^https?:\/\//i.test(report.value.url) ? report.value.url : new URL(report.value.url, window.location.origin).href; return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(absoluteUrl)}` })
onMounted(async () => { await loadTaskConfig(); await loadSavedAnswer() })
function createForm() { return { projectOverview: { projectBackground: "", productPositioning: "", projectGoals: "", macroEnvironmentConclusion: "", competitiveLandscapeConclusion: "" }, marketAnalysis: { annualMarketSize: "", monthlyGrowthRate: "", topBrandMarketShare: "", competitorsPriceRange: "", marketConcentrationCr3: "" }, targetUsers: Array.from({ length: 3 }, () => ({ userGroup: "", scenario: "", coreNeed: "", spendingPower: "" })), businessModels: [{ module: "产品销售渠道", strategy: "", explanation: "" }, { module: "增值服务", strategy: "", explanation: "" }], costs: [{ id: "hardware-bom", label: "硬件 BOM 成本", amount: "" }, { id: "manufacturing", label: "生产制造费用", amount: "" }, { id: "rd-amortization", label: "研发费用摊销", amount: "" }, { id: "channel-marketing", label: "渠道与营销费用", amount: "" }, { id: "logistics-warehouse", label: "物流仓储费用", amount: "" }], targetPrice: "", risks: ["技术风险", "市场风险", "供应链风险", "资金风险"].map((type) => ({ type, description: "", impact: "", response: "" })) } }
function createStepTwo() { return { decision: "", priorities: [] } }
function createReport() { return { url: "", name: "", size: 0 } }
function numeric(value) { const number = Number(value); return Number.isFinite(number) ? number : 0 }
function currency(value) { return Number(value || 0).toFixed(2) }
function percent(value) { return value === null || !Number.isFinite(value) ? "--" : `${Number(value).toFixed(2)}%` }
function parseArray(value, fallback) { if (Array.isArray(value) && value.length) return value; if (!value) return fallback; try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length ? parsed.map((item) => String(item?.name ?? item).trim()).filter(Boolean) : fallback } catch (_) { const items = String(value).split(/[,,|]/).map((item) => item.trim()).filter(Boolean); return items.length ? items : fallback } }
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null } catch (_) { taskConfig.value = null } }
async function loadSavedAnswer() { try { const saved = await getStudentTrainingAnswer(TASK_KEY)?.data; if (!saved) return; if (saved.step1Answer) { const parsed = JSON.parse(saved.step1Answer); form.value = mergeForm(parsed?.form || parsed); completed.value = true } if (saved.step2Answer) { const parsed = JSON.parse(saved.step2Answer); stepTwo.value = { ...createStepTwo(), ...(parsed?.stepTwo || parsed || {}), priorities: Array.isArray(parsed?.stepTwo?.priorities || parsed?.priorities) ? (parsed?.stepTwo?.priorities || parsed?.priorities) : [] }; stepTwoCompleted.value = true } if (saved.step3Answer) { const parsed = JSON.parse(saved.step3Answer); report.value = { ...createReport(), ...(parsed?.report || parsed || {}) }; stepThreeCompleted.value = Boolean(report.value.url) } if (Number(saved.currentStep) > 0) currentStep.value = Number(saved.currentStep) } catch (_) { /* keep a new answer editable */ } }
function mergeForm(value) { const fallback = createForm(); const merged = { ...fallback, ...(value || {}), projectOverview: { ...fallback.projectOverview, ...(value?.projectOverview || {}) }, marketAnalysis: { ...fallback.marketAnalysis, ...(value?.marketAnalysis || {}) }, targetUsers: Array.isArray(value?.targetUsers) && value.targetUsers.length === 3 ? value.targetUsers : fallback.targetUsers, businessModels: Array.isArray(value?.businessModels) && value.businessModels.length === 2 ? value.businessModels : fallback.businessModels, costs: fallback.costs.map((cost) => ({ ...cost, ...(value?.costs || []).find((item) => item?.id === cost.id) })), risks: fallback.risks.map((risk) => ({ ...risk, ...(value?.risks || []).find((item) => item?.type === risk.type) })) }; return merged }
function payload() { return { ...form.value, costs: form.value.costs.map(({ id, amount }) => ({ id, amount: amount === "" ? null : Number(amount) })), targetPrice: form.value.targetPrice === "" ? null : Number(form.value.targetPrice) } }
async function validateAndContinue() { if (saving.value) return; saving.value = true; try { const result = await checkBusinessFeasibilityStepOne(payload()); const calculated = result?.data || {}; form.value.costs = form.value.costs.map((cost) => ({ ...cost, amount: cost.amount === "" ? "" : Number(cost.amount) })); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify({ form: form.value, calculations: { unitTotalCost: calculated.unitTotalCost ?? totalCost.value, unitGrossProfit: calculated.unitGrossProfit ?? unitGrossProfit.value, grossMargin: calculated.grossMargin ?? grossMargin.value } }), currentStep: 2, submitted: false, saveAction: "SAVE" }); completed.value = true; currentStep.value = 2; proxy?.$modal?.msgSuccess("已完成校验并保存,已进入第 2 步") } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请完整填写本步骤内容后再提交") } finally { saving.value = false } }
function resetForm() { form.value = createForm(); completed.value = false; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容") }
async function saveStepTwo() { if (saving.value) return; saving.value = true; try { await checkBusinessFeasibilityStepTwo({ ...stepTwo.value }); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify({ stepTwo: stepTwo.value }), currentStep: 3, submitted: false, saveAction: "SAVE" }); stepTwoCompleted.value = true; currentStep.value = 3; proxy?.$modal?.msgSuccess("已完成校验并保存,已进入第 3 步") } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请选择总体评估,并按要求填写优先问题") } finally { saving.value = false } }
function resetStepTwo() { stepTwo.value = createStepTwo(); stepTwoCompleted.value = false; proxy?.$modal?.msgSuccess("已清空第 2 步填写内容") }
function handleDecisionChange() { if (stepTwo.value.decision === "not-feasible") stepTwo.value.priorities = [] }
function openReportPicker() { reportInput.value?.click() }
async function handleReportUpload(event) { const file = event?.target?.files?.[0]; if (event?.target) event.target.value = ""; if (!file) return; const extension = file.name.split(".").pop()?.toLowerCase(); if (!["pdf", "doc", "docx", "png", "jpg", "jpeg"].includes(extension)) { proxy?.$modal?.msgError("仅支持 PDF、Word、PNG、JPG 格式的报告附件"); return } if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgError("报告附件不能超过 20MB"); return } uploading.value = true; try { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); const url = response?.url || response?.data?.url || ""; if (!url) throw new Error("上传未返回文件地址,请重新选择报告附件"); report.value = { url, name: file.name, size: file.size }; proxy?.$modal?.msgSuccess("报告附件上传成功,可点击预览核对") } catch (error) { proxy?.$modal?.msgError?.(error?.message || "报告附件上传失败,请重试") } finally { uploading.value = false } }
function resolveReportKind(file) { const name = `${file?.name || ""} ${file?.url || ""}`.toLowerCase(); if (/\.(png|jpe?g)(?:\?|$)/.test(name)) return "image"; if (/\.pdf(?:\?|$)/.test(name)) return "pdf"; return "word" }
function readableSize(size) { const value = Number(size || 0); return value > 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : value > 1024 ? `${(value / 1024).toFixed(1)} KB` : "附件已上传" }
function openReportPreview() { if (report.value.url) reportPreviewVisible.value = true }
function downloadReport() { if (!report.value.url) return; const link = document.createElement("a"); link.href = report.value.url; link.target = "_blank"; link.rel = "noopener"; link.download = report.value.name || "可行性论证报告"; document.body.appendChild(link); link.click(); document.body.removeChild(link) }
function clearReport() { report.value = createReport(); reportPreviewVisible.value = false; stepThreeCompleted.value = false }
async function saveStepThree() { if (saving.value) return; saving.value = true; try { await checkBusinessFeasibilityStepThree({ reportUrl: report.value.url, reportName: report.value.name }); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify({ report: report.value }), currentStep: 4, submitted: false, saveAction: "SAVE" }); stepThreeCompleted.value = true; currentStep.value = 4; proxy?.$modal?.msgSuccess("报告已保存,已进入第 4 步") } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请先上传可行性论证报告") } finally { saving.value = false } }
function switchStep(step) { if (step > 1 && !completed.value) { proxy?.$modal?.msgWarning("请先完成并保存第 1 步"); return } if (step > 2 && !stepTwoCompleted.value) { proxy?.$modal?.msgWarning("请先完成并保存第 2 步"); return } if (step > 3 && !stepThreeCompleted.value) { proxy?.$modal?.msgWarning("请先上传并保存第 3 步报告"); return } currentStep.value = step }
function exportOutcome() { const blob = new Blob([JSON.stringify({ task: taskTitle.value, step: 1, ...payload(), calculations: { unitTotalCost: totalCost.value, unitGrossProfit: unitGrossProfit.value, grossMargin: grossMargin.value } }, null, 2)], { type: "application/json;charset=utf-8" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "商业可行性论证-第1步.json"; link.click(); URL.revokeObjectURL(link.href) }
</script>
<style lang="scss" scoped>
@ -507,7 +741,9 @@ function exportOutcome() { const blob = new Blob([JSON.stringify({ task: taskTit
border-bottom: 1px solid var(--student-panel-border);
}
.form-table label:last-child { border-bottom: 0; }
.form-table label:last-child {
border-bottom: 0;
}
.form-table label>span {
display: flex;
@ -520,17 +756,21 @@ function exportOutcome() { const blob = new Blob([JSON.stringify({ task: taskTit
font-size: 13px;
}
.form-table label :deep(.el-textarea),
.form-table label :deep(.el-input-number),
.form-table label :deep(.el-input) {
margin: 8px 10px;
max-width: calc(100% - 20px);
.market-table {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.market-table { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.market-table label { border-right: 1px solid var(--student-panel-border); }
.market-table label:nth-child(2n) { border-right: 0; }
.market-table label:last-child { border-bottom: 0; }
.market-table label {
border-right: 1px solid var(--student-panel-border);
}
.market-table label:nth-child(2n) {
border-right: 0;
}
.market-table label:last-child {
border-bottom: 0;
}
.input-with-suffix {
display: flex;
@ -567,14 +807,21 @@ th {
text-align: center;
}
th, td {
th,
td {
border: 1px solid var(--student-panel-border);
padding: 8px 10px;
vertical-align: middle;
}
tr:last-child td { border-bottom: 0; }
th:last-child, td:last-child { border-right: 0; }
tr:last-child td {
border-bottom: 0;
}
th:last-child,
td:last-child {
border-right: 0;
}
/* Element Plus 组件在表格中的紧凑样式 */
.table-wrap td :deep(.el-textarea),
@ -821,8 +1068,13 @@ th:last-child, td:last-child { border-right: 0; }
background: var(--student-accent);
}
.priority-section { transition: opacity 0.16s ease; }
.priority-section.muted { opacity: 0.45; }
.priority-section {
transition: opacity 0.16s ease;
}
.priority-section.muted {
opacity: 0.45;
}
.priority-heading {
display: flex;
@ -862,9 +1114,19 @@ th:last-child, td:last-child { border-right: 0; }
border-radius: 0 10px 10px 0;
}
.report-guide p, .report-guide ol { margin: 0; }
.report-guide ol { margin-top: 6px; padding-left: 18px; }
.report-guide li + li { margin-top: 3px; }
.report-guide p,
.report-guide ol {
margin: 0;
}
.report-guide ol {
margin-top: 6px;
padding-left: 18px;
}
.report-guide li+li {
margin-top: 3px;
}
.report-upload-panel {
display: grid;
@ -929,9 +1191,13 @@ th:last-child, td:last-child { border-right: 0; }
font-size: 22px;
}
.report-file-meta { min-width: 0; flex: 1; }
.report-file-meta {
min-width: 0;
flex: 1;
}
.report-file-meta strong, .report-file-meta span {
.report-file-meta strong,
.report-file-meta span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
@ -950,9 +1216,14 @@ th:last-child, td:last-child { border-right: 0; }
font-size: 11px;
}
.report-file-actions { display: flex; gap: 6px; flex-shrink: 0; }
.report-file-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.report-file-actions button, .text-action {
.report-file-actions button,
.text-action {
display: inline-flex;
align-items: center;
gap: 4px;
@ -973,7 +1244,10 @@ th:last-child, td:last-child { border-right: 0; }
&.danger {
color: var(--student-danger);
&:hover { background: rgba(217, 85, 76, 0.08); }
&:hover {
background: rgba(217, 85, 76, 0.08);
}
}
}
@ -985,7 +1259,10 @@ th:last-child, td:last-child { border-right: 0; }
font-size: 11px;
}
.text-action:disabled { cursor: not-allowed; opacity: 0.5; }
.text-action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.visually-hidden {
position: absolute;
@ -1022,26 +1299,71 @@ th:last-child, td:last-child { border-right: 0; }
/* ===== 响应式 ===== */
@media (max-width: 1280px) {
.step-indicator { overflow-x: auto; }
.step-tab { min-width: 140px; }
.market-table { grid-template-columns: 1fr; }
.market-table label { border-right: 0; }
.step-indicator {
overflow-x: auto;
}
.step-tab {
min-width: 140px;
}
.market-table {
grid-template-columns: 1fr;
}
.market-table label {
border-right: 0;
}
}
@media (max-width: 900px) {
.form-table label { grid-template-columns: 1fr; }
.form-table label > span { justify-content: flex-start; padding: 8px 12px; }
.decision-grid, .priority-grid { grid-template-columns: 1fr; }
.step-heading em { display: none; }
.step-actions { flex-wrap: wrap; }
.form-table label {
grid-template-columns: 1fr;
}
.form-table label>span {
justify-content: flex-start;
padding: 8px 12px;
}
.decision-grid,
.priority-grid {
grid-template-columns: 1fr;
}
.step-heading em {
display: none;
}
.step-actions {
flex-wrap: wrap;
}
}
@media (max-width: 720px) {
.workbench-actions { justify-content: flex-start; flex-wrap: wrap; }
.report-upload-panel { padding: 20px 14px; }
.report-file-card { align-items: flex-start; flex-wrap: wrap; }
.report-file-actions { width: 100%; justify-content: flex-end; }
.report-replace { flex-wrap: wrap; justify-content: center; }
.workbench-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.report-upload-panel {
padding: 20px 14px;
}
.report-file-card {
align-items: flex-start;
flex-wrap: wrap;
}
.report-file-actions {
width: 100%;
justify-content: flex-end;
}
.report-replace {
flex-wrap: wrap;
justify-content: center;
}
}
</style>
@ -1088,7 +1410,9 @@ th:last-child, td:last-child { border-right: 0; }
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
.step-bar { display: none !important; }
.step-bar {
display: none !important;
}
.step-no {
position: static !important;
@ -1109,30 +1433,43 @@ th:last-child, td:last-child { border-right: 0; }
letter-spacing: 0 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
&::after { display: none !important; }
&::after {
display: none !important;
}
}
.step-name { color: inherit !important; }
.step-name {
color: inherit !important;
}
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-no { background: #1f2937 !important; color: #ffffff !important; }
.step-no {
background: #1f2937 !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-no { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
.step-no {
background: rgba(74, 157, 110, 0.12) !important;
color: #3f8a5f !important;
}
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-no { background: #4a9d6e !important; color: #ffffff !important; }
.step-no {
background: #4a9d6e !important;
color: #ffffff !important;
}
}
}
@ -1142,7 +1479,10 @@ th:last-child, td:last-child { border-right: 0; }
color: #ffffff !important;
font-weight: 700 !important;
.step-no { background: #ffffff !important; color: #1f2937 !important; }
.step-no {
background: #ffffff !important;
color: #1f2937 !important;
}
&:hover {
background: #111827 !important;

@ -1,4 +1,4 @@
<template>
<template>
<div class="consumer-behavior-page student-training-shell">
<main class="training-core">
<section class="task-header">

@ -1467,23 +1467,23 @@ function exportOutcome() {
display: flex;
gap: 8px;
padding: 0 20px 14px;
border-bottom: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border, #e5e7eb);
button {
min-width: 52px;
height: 30px;
border: 1px solid var(--student-panel-border);
border: 1px solid var(--student-panel-border, #e5e7eb);
border-radius: 6px;
color: var(--student-text-secondary);
color: var(--student-text-secondary, #6b7280);
background: #ffffff;
cursor: pointer;
font-weight: 700;
font-size: 12px;
&.active {
border-color: var(--student-accent);
border-color: #1f2937;
color: #ffffff;
background: var(--student-accent);
background: #1f2937;
}
}
}
@ -1491,13 +1491,13 @@ function exportOutcome() {
.interview-dialog-body--panel {
max-height: 58vh;
padding: 20px;
color: var(--student-text);
color: var(--student-text, #1f2937);
font-size: 13px;
line-height: 1.65;
}
.interview-line, .interview-title, .interview-qa p { margin: 0; }
.interview-title { margin-top: 14px; color: var(--student-text); font-weight: 800; }
.interview-title { margin-top: 14px; color: var(--student-text, #1f2937); font-weight: 800; }
.interview-qa { margin-top: 10px; }
/* 画像表格 */

Loading…
Cancel
Save