You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/demand/business-feasibility.vue

1604 lines
65 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="feasibility-page student-training-shell">
<main class="feasibility-core">
<section class="task-header">
<div class="task-head-main">
<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>
<div class="workbench-actions">
<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>
</div>
</section>
<section class="workbench">
<nav class="step-indicator" aria-label="商业可行性论证步骤">
<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)">
<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>
</button>
</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>
<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="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>
</div>
</section>
<section class="form-section">
<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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
<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">
<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>
<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">
<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 步' }}<el-icon><ArrowRight /></el-icon></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>
<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>
</section>
<div class="step-actions"><button class="action-button primary" type="button"
:disabled="saving || uploading" @click="saveStepThree">{{ saving ? '校验并保存中…' : '保存并进入第 4 步'
}}<el-icon><ArrowRight /></el-icon></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>
</main>
<TrainingAiSidebar study-tip="先从案例资料中提炼可验证的市场、用户与成本依据,再形成一致的商业论证。" reference-title="可行性论证提示"
reference-text="• 结论要有数据或前序分析支撑<br />• 价格、成本和毛利需要逻辑一致<br />• 风险应附带可执行的应对措施"
:progress-items="progressItems" />
</div>
</template>
<script setup>
import { ArrowRight, Check, CircleCheck, 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: "相关教程课程合作" },
{ value: "channel-expansion", label: "渠道拓展", detail: "线上流量获取" },
{ 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) }
</script>
<style lang="scss" scoped>
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.feasibility-page {
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
--student-danger: #ef4444;
--student-success: #2ba471;
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
--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;
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
max-width: 100%;
min-height: calc(100vh - 106px);
margin: 0;
padding: 24px 24px 32px;
color: var(--student-text);
background: #ffffff;
line-height: 1.55;
}
.feasibility-core {
min-width: 0;
display: grid;
gap: 10px;
}
/* task-header 独立卡片 */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: 17px;
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;
b {
color: var(--student-text);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* workbench:扁平化容器 */
.workbench {
margin-top: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 12px;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
gap: 8px;
}
/* action-button 基础样式(次按钮:白底描边) */
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
color: var(--student-accent);
background: #eff6ff;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
&.primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-weight: 700;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.22);
&:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
color: #ffffff;
box-shadow: 0 4px 12px rgba(29, 78, 216, 0.30);
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
}
}
/* step-card:独立卡片 */
.step-card {
padding: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 16px;
background: var(--student-card-bg);
}
.step-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.section-eyebrow {
margin: 0 0 2px;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
}
.step-heading h2 {
margin: 0;
color: var(--student-text);
font-size: 15px;
font-weight: 700;
}
.step-heading em {
padding: 4px 10px;
border: 1px solid #d6dae0;
border-radius: 999px;
color: var(--student-accent-light);
background: var(--student-accent-soft);
font-size: 11px;
font-style: normal;
font-weight: 700;
white-space: nowrap;
}
.intro {
margin: 10px 0 0;
color: #374151;
font-size: 13px;
line-height: 1.6;
}
.form-section {
margin-top: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.section-title>span {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 0;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent);
font-size: 11px;
font-weight: 900;
flex-shrink: 0;
}
.section-title h3 {
margin: 0;
color: var(--student-text);
font-size: 14px;
font-weight: 700;
}
.section-title p {
margin: 2px 0 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.55;
}
/* form-table:扁平化表格 */
.form-table {
display: grid;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
overflow: hidden;
background: #ffffff;
}
.form-table label {
display: grid;
grid-template-columns: 180px 1fr;
border-bottom: 1px solid var(--student-panel-border);
}
.form-table label:last-child {
border-bottom: 0;
}
.form-table label>span {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 10px 12px;
color: var(--student-text);
background: var(--student-panel-bg);
font-weight: 700;
font-size: 13px;
}
.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;
}
.input-with-suffix {
display: flex;
align-items: center;
gap: 8px;
}
.input-with-suffix b {
color: var(--student-text-secondary);
font-size: 12px;
white-space: nowrap;
font-weight: 700;
}
/* table-wrap + Element Plus table 内的表单组件 */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
}
th {
color: var(--student-text);
font-size: 13px;
font-weight: 800;
background: var(--student-panel-bg);
text-align: center;
}
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;
}
/* Element Plus 组件在表格中的紧凑样式 */
.table-wrap td :deep(.el-textarea),
.table-wrap td :deep(.el-input),
.table-wrap td :deep(.el-select) {
max-width: 100%;
}
.table-wrap td :deep(.el-textarea__inner) {
min-height: 60px !important;
padding: 6px 10px;
font-size: 13px;
line-height: 1.55;
}
.table-wrap td :deep(.el-input__wrapper),
.table-wrap td :deep(.el-textarea__inner),
.table-wrap td :deep(.el-select__wrapper) {
border-color: #d1d5db !important;
border-radius: 8px;
background: #ffffff;
box-shadow: none !important;
&:hover {
border-color: var(--student-accent-light) !important;
}
}
.table-wrap td :deep(.el-input__wrapper.is-focus),
.table-wrap td :deep(.el-textarea__inner:focus),
.table-wrap td :deep(.el-select__wrapper.is-focused) {
border-color: var(--student-accent) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15) !important;
}
.table-wrap td :deep(.el-input-number) {
width: 100%;
}
.table-wrap td :deep(.el-input-number .el-input__wrapper) {
padding: 0 8px;
}
.fixed-cell {
color: var(--student-text);
background: var(--student-panel-bg);
text-align: center;
font-weight: 700;
font-size: 13px;
}
.computed-value {
display: inline-block;
color: var(--student-text);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.total-row td {
color: var(--student-accent);
background: var(--student-accent-soft);
font-weight: 800;
font-size: 13px;
}
.step-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 24px;
}
/* placeholder */
.placeholder {
display: grid;
min-height: 240px;
place-content: center;
justify-items: center;
gap: 14px;
color: var(--student-text-secondary);
text-align: center;
}
.placeholder p {
font-size: 15px;
font-weight: 700;
color: var(--student-text);
}
/* ===== Step 2 决策区 ===== */
.decision-section {
margin-top: 24px;
padding: 12px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: var(--student-panel-bg);
}
.decision-section h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--student-text);
font-size: 14px;
font-weight: 700;
}
.decision-section h3>span {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent);
font-size: 10px;
font-weight: 900;
flex-shrink: 0;
}
/* decision-grid 使用 Element Plus el-radio-group */
.decision-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
:deep(.decision-option.el-radio) {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: start;
padding: 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: #374151;
background: #ffffff;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
margin-right: 0;
height: auto;
strong {
color: var(--student-text);
font-size: 13px;
font-weight: 700;
}
small {
display: block;
margin-top: 3px;
color: var(--student-text-secondary);
font-size: 11px;
line-height: 1.5;
}
&:hover {
border-color: var(--student-accent-light);
transform: translateY(-1px);
}
}
/* 墨黑单选圆点 */
:deep(.decision-option.el-radio .el-radio__input .el-radio__inner) {
border-color: #9ca3af;
}
:deep(.decision-option.el-radio .el-radio__input.is-checked .el-radio__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
:deep(.decision-option.el-radio .el-radio__input.is-checked .el-radio__inner::after) {
background: #ffffff;
}
:deep(.decision-option.el-radio.is-checked) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
:deep(.decision-option.el-radio.is-checked .el-radio__label) {
color: var(--student-accent);
}
/* priority-grid */
.priority-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
:deep(.priority-option.el-checkbox) {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: start;
padding: 10px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: #374151;
background: #ffffff;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
margin-right: 0;
height: auto;
strong {
display: block;
color: var(--student-text);
font-size: 13px;
font-weight: 700;
}
small {
display: block;
margin-top: 3px;
color: var(--student-text-secondary);
font-size: 11px;
line-height: 1.5;
}
&:hover:not(.is-disabled) {
border-color: var(--student-accent-light);
transform: translateY(-1px);
}
&.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
/* 墨黑复选框 */
:deep(.priority-option.el-checkbox .el-checkbox__inner) {
border-color: #9ca3af;
background: #ffffff;
}
:deep(.priority-option.el-checkbox.is-checked .el-checkbox__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
.priority-section {
transition: opacity 0.16s ease;
}
.priority-section.muted {
opacity: 0.45;
}
.priority-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.priority-heading b {
min-width: 44px;
padding: 4px 8px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent);
background: var(--student-accent-soft);
text-align: center;
font-size: 11px;
font-weight: 800;
}
.hint {
margin: 10px 0 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.55;
}
/* ===== Step 3 报告区 ===== */
.report-guide {
margin-top: 16px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
color: #374151;
background: var(--student-accent-soft);
line-height: 1.7;
font-size: 13px;
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-upload-panel {
display: grid;
min-height: 260px;
place-content: center;
justify-items: center;
gap: 10px;
margin-top: 20px;
padding: 30px;
border: 1px dashed #d1d5db;
border-radius: 14px;
background: var(--student-panel-bg);
text-align: center;
}
.upload-orbit {
display: grid;
width: 56px;
height: 56px;
place-items: center;
border: 1px solid var(--student-panel-border);
border-radius: 16px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: 26px;
}
.report-upload-panel h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: 15px;
font-weight: 700;
}
.report-upload-panel>p {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
}
.report-file-card {
display: flex;
align-items: center;
width: min(720px, 100%);
gap: 12px;
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
text-align: left;
}
.report-file-icon {
display: grid;
flex: 0 0 40px;
width: 40px;
height: 40px;
place-items: center;
border-radius: 10px;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: 22px;
}
.report-file-meta {
min-width: 0;
flex: 1;
}
.report-file-meta strong,
.report-file-meta span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-file-meta strong {
color: var(--student-text);
font-size: 13px;
font-weight: 700;
}
.report-file-meta span {
margin-top: 3px;
color: var(--student-text-secondary);
font-size: 11px;
}
.report-file-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.report-file-actions button,
.text-action {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
color: var(--student-accent);
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
transition: background 0.16s ease;
&:hover {
background: var(--student-accent-soft);
}
&.danger {
color: var(--student-danger);
&:hover {
background: rgba(217, 85, 76, 0.08);
}
}
}
.report-replace {
display: flex;
align-items: center;
gap: 10px;
color: var(--student-text-secondary);
font-size: 11px;
}
.text-action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.report-preview {
display: grid;
min-height: 64vh;
place-items: center;
background: #ffffff;
}
.report-preview .el-image {
width: 100%;
height: 64vh;
}
.report-preview iframe {
width: 100%;
height: 64vh;
border: 0;
background: #ffffff;
}
/* 风险评估下拉框宽度 */
.risk-impact-select {
width: 100%;
}
/* ===== 响应式 ===== */
@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;
}
}
@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;
}
}
@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;
}
}
</style>
<style lang="scss">
/* ===== 步骤导航:分段连接胶囊(SKILL.md 规格) ===== */
#app .studentLayout section.app-main:has(.feasibility-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-tab {
flex: 1 1 0 !important;
max-width: 240px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-bar {
display: none !important;
}
.step-no {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no {
background: #2ba471 !important;
color: #ffffff !important;
}
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-no {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* 窄屏:横向滑动 */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.feasibility-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.feasibility-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.feasibility-page .workbench-actions .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ============================================================
* 顶部导出按钮(原生 <button.action-button>)
* ============================================================ */
#app .studentLayout section.app-main:has(.feasibility-page) .workbench-actions > button.action-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.feasibility-page) .workbench-actions > button.action-button:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: none !important;
}
</style>