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/product/price.vue

2926 lines
104 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="price-page student-training-shell">
<main class="price-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>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="saveStepFive" :on-export="exportPriceOutcome" />
</section>
<section class="price-workbench">
<nav class="step-indicator" aria-label="新产品定价步骤">
<template v-for="step in pricingSteps" :key="step.no">
<button type="button" class="step-tab"
:class="{ active: currentStep === step.no, complete: currentStep > step.no }"
:aria-current="currentStep === step.no ? 'step' : undefined" @click="switchStep(step.no)">
<span class="step-marker">{{ String(step.no).padStart(2, "0") }}</span>
<span class="step-copy">
<strong>{{ step.title }}</strong>
</span>
</button>
<span v-if="step.no < pricingSteps.length" :class="{ complete: currentStep > step.no }"
class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section class="step-card">
<header class="step-title-row">
<div>
<p class="section-eyebrow">STEP {{ String(currentStep).padStart(2, "0") }}</p>
<h3>{{ currentStepTitle }}</h3>
</div>
</header>
<template v-if="currentStep === 1">
<p class="intro-copy">{{ labels.formulaTip }}</p>
<section class="unit-cost-panel" :aria-label="labels.unitCost">
<div class="cost-panel-copy">
<span class="cost-dot"></span>
<div>
<p>{{ labels.unitCost }}</p>
<small>{{ labels.unitCostHelp }}</small>
</div>
</div>
<el-input v-model="unitCost" class="unit-cost-input" inputmode="decimal"
:placeholder="labels.unitCostPlaceholder" @input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</section>
<section class="pricing-table-wrap">
<table class="pricing-table">
<thead>
<tr>
<th>{{ labels.serial }}</th>
<th>{{ labels.channel }}</th>
<th>{{ labels.markupRate }}</th>
<th>{{ labels.salePrice }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in pricingRows" :key="row.id">
<td class="serial-cell">{{ String(index + 1).padStart(2, "0") }}</td>
<td class="channel-cell">{{ row.channel }}</td>
<td>
<el-input v-model="row.markupRate" class="markup-input" inputmode="decimal"
:placeholder="ratePlaceholder(row)" @input="clearValidation">
<template #append>%</template>
</el-input>
<small class="range-hint">{{ rateHint(row) }}</small>
</td>
<td class="sale-price-cell">
<span v-if="salePrice(row) !== null">{{ labels.yuanSign }} {{
formatMoney(salePrice(row)) }}</span>
<span v-else class="empty-price">{{ labels.waitForInput }}</span>
</td>
</tr>
</tbody>
</table>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
<footer class="workbench-footer">
<p>{{ labels.footerTip }}</p>
<div class="footer-actions">
<el-button plain :disabled="saving" @click="resetStepOne">{{ labels.clear }}</el-button>
</div>
</footer>
</template>
<template v-else-if="currentStep === 3">
<section class="value-pricing-section">
<div class="value-pricing-toolbar">
<p>{{ labels.valueBasedPricingIntro }}</p>
<div class="toolbar-actions">
<button type="button" class="primary-button" @click="addPerceivedValueItem">{{
labels.valueBasedAddRow }}</button>
<button type="button" class="secondary-button"
:disabled="!canRemovePerceivedValueItem" @click="removePerceivedValueItem">
{{ labels.valueBasedRemoveRow }}
</button>
</div>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table perceived-value-table">
<thead>
<tr>
<th>{{ labels.perceivedValueItem }}</th>
<th>{{ labels.perceivedValueAmount }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in perceivedValueItems" :key="row.id">
<td><el-input v-model="row.name"
:placeholder="labels.perceivedValueItemPlaceholder"
@input="clearValidation" /></td>
<td>
<el-input v-model="row.perceivedValue" inputmode="decimal"
:placeholder="labels.perceivedValueAmountPlaceholder"
@input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</td>
</tr>
<tr class="total-row">
<td>{{ labels.perceivedValueTotal }}</td>
<td class="sale-price-cell">
<span v-if="perceivedValueTotal !== null">{{ labels.yuanSign }} {{
formatMoney(perceivedValueTotal) }}</span>
<span v-else class="empty-price">{{ labels.waitForInput }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<section class="value-pricing-result">
<label>
<span>{{ labels.profitDiscountRate }}</span>
<el-input v-model="perceivedValueDiscountRate" inputmode="decimal"
:placeholder="labels.discountRatePlaceholder" @input="clearValidation">
<template #append>%</template>
</el-input>
</label>
<div class="value-price-output">
<span>{{ labels.valueBasedPrice }}</span>
<strong v-if="valueBasedPrice !== null">{{ labels.yuanSign }} {{
formatMoney(valueBasedPrice) }}</strong>
<em v-else>{{ labels.waitForInput }}</em>
</div>
<p>{{ labels.valueBasedPriceFormula }}</p>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
<footer class="workbench-footer">
<p>{{ labels.valueBasedFooterTip }}</p>
<div class="footer-actions">
<el-button plain :disabled="saving" @click="resetStepThree">{{ labels.clearStepThree
}}</el-button>
</div>
</footer>
</section>
</template>
<template v-else-if="currentStep === 4">
<section class="psm-section">
<div class="psm-toolbar">
<div>
<p>{{ labels.psmIntro }}</p>
<p class="psm-subtitle">{{ labels.psmDataTitle }}</p>
</div> <div class="toolbar-actions">
<button type="button" class="primary-button" @click="addPsmRow">{{ labels.psmAddRow
}}</button>
<button type="button" class="secondary-button" :disabled="!canRemovePsmRow"
@click="removePsmRow">
{{ labels.psmRemoveRow }}
</button>
<button type="button" class="secondary-button" @click="exportPsmTable">{{
labels.psmExport }}</button>
</div>
</div>
<div class="table-scroll">
<table class="price-table psm-table">
<thead>
<tr>
<th>{{ labels.psmPriceRange }}</th>
<th>{{ labels.psmTooExpensive }}</th>
<th>{{ labels.psmTooCheap }}</th>
<th>{{ labels.psmExpensiveAcceptable }}</th>
<th>{{ labels.psmCheapValue }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in psmRows" :key="row.id">
<td>
<span v-if="row.isDefault">{{ row.priceRange }}</span>
<input v-else v-model="row.priceRange" type="text"
:placeholder="labels.psmCustomRangePlaceholder" />
</td>
<td><input v-model="row.tooExpensive" type="number" min="0" max="100"
step="0.01" :placeholder="labels.psmPercentagePlaceholder"
@input="clearValidation" /></td>
<td><input v-model="row.tooCheap" type="number" min="0" max="100"
step="0.01" :placeholder="labels.psmPercentagePlaceholder"
@input="clearValidation" /></td>
<td><input v-model="row.expensiveAcceptable" type="number" min="0" max="100"
step="0.01" :placeholder="labels.psmPercentagePlaceholder"
@input="clearValidation" /></td>
<td><input v-model="row.cheapValue" type="number" min="0" max="100"
step="0.01" :placeholder="labels.psmPercentagePlaceholder"
@input="clearValidation" /></td>
</tr>
</tbody>
</table>
</div>
<section class="psm-chart-section">
<div class="psm-chart-head">
<div>
<h3>{{ labels.psmChartTitle }}</h3>
<p>根据上表,绘制PSM价格数据折线图:</p>
</div>
<button type="button" class="primary-button" :disabled="uploadingPsmChart"
@click="triggerPsmChartUpload">
{{ uploadingPsmChart ? labels.uploading : labels.psmUploadChart }}
</button>
</div>
<input ref="psmChartInput" class="file-picker" type="file" accept="image/*"
@change="handlePsmChartUpload" />
<div v-if="psmChart.url" class="psm-chart-preview">
<el-image :src="psmChart.url" :preview-src-list="psmChartPreviewList" fit="cover"
class="psm-chart-image" preview-teleported />
<div>
<p>{{ psmChart.name }}</p>
<button type="button" class="text-button" @click="removePsmChart">{{
labels.psmChartRemove }}</button>
</div>
</div>
</section>
<section class="psm-point-section">
<h3>{{ labels.psmPricePointTitle }}</h3>
<div class="psm-point-grid">
<label>
<span>{{ labels.psmOptimalPrice }}</span>
<input v-model="psmPricePoints.optimalPrice" type="number" min="0" step="0.01"
:placeholder="labels.psmPricePointPlaceholder" @input="clearValidation" />
</label>
<label>
<span>{{ labels.psmAcceptablePrice }}</span>
<input v-model="psmPricePoints.acceptablePrice" type="number" min="0"
step="0.01" :placeholder="labels.psmPricePointPlaceholder"
@input="clearValidation" />
</label>
<label>
<span>{{ labels.psmAcceptableRange }}</span>
<input v-model="psmPricePoints.acceptableRange" type="text"
:placeholder="labels.psmPricePointPlaceholder" @input="clearValidation" />
</label>
</div>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
</section>
</template>
<template v-else-if="currentStep === 5">
<section class="final-pricing-section">
<p class="intro-copy final-pricing-intro">{{ labels.finalPricingIntro }}</p>
<div class="final-pricing-channel">
<div>
<h3>{{ labels.costMarkupChannel }}</h3>
<p>{{ labels.costMarkupChannelHint }}</p>
</div>
<el-select v-model="selectedCostMarkupChannel"
:placeholder="labels.costMarkupChannelPlaceholder" @change="clearValidation">
<el-option v-for="option in costMarkupChannelOptions" :key="option.value"
:label="option.label" :value="option.value" :disabled="option.disabled" />
</el-select>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table final-pricing-table">
<thead>
<tr>
<th>{{ labels.pricingMethod }}</th>
<th>{{ labels.measuredResult }}</th>
<th>{{ labels.grossMarginRate }}</th>
</tr>
</thead>
<tbody>
<tr v-for="result in stepFiveMethodResults" :key="result.key">
<td>{{ result.methodName }}</td>
<td>{{ result.measuredPrice === null ? labels.waitForInput :
`${labels.yuanSign} ${formatMoney(result.measuredPrice)}` }}</td>
<td>{{ result.grossMarginRate === null ? labels.waitForInput :
`${formatMoney(result.grossMarginRate)}%` }}</td>
</tr>
</tbody>
</table>
</div>
<section class="final-analysis-section">
<h3>{{ labels.finalAnalysisTitle }}</h3>
<el-input v-model="finalPricingAnalysis" type="textarea"
:autosize="{ minRows: 7, maxRows: 12 }"
:placeholder="labels.finalAnalysisPlaceholder" @input="clearValidation" />
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
</section>
</template>
<template v-else-if="currentStep === 2">
<p class="intro-copy">{{ labels.competitorIntro }}</p>
<section class="competitor-section">
<div class="subsection-title">
<span>01</span>
<h3>{{ labels.competitorAnalysis }}</h3>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table competitor-table">
<thead>
<tr>
<th>{{ labels.competitorProductType }}</th>
<th>{{ labels.competitorBrand }}</th>
<th>{{ labels.competitorEcommercePrice }}</th>
<th>{{ labels.competitorDifferentiator }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in competitorRows" :key="row.id">
<td>
<el-input v-model="row.productType" :placeholder="row.label"
@input="clearValidation" />
</td>
<td>
<el-input v-model="row.brand" :placeholder="labels.brandPlaceholder"
@input="clearValidation" />
</td>
<td>
<el-input v-model="row.ecommercePrice" inputmode="decimal"
:placeholder="labels.pricePlaceholder" @input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</td>
<td>
<el-input v-model="row.differentiator" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="labels.differentiatorPlaceholder"
@input="clearValidation" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="competitor-section">
<div class="subsection-title">
<span>02</span>
<h3>{{ labels.decisionTitle }}</h3>
</div>
<div class="pricing-table-wrap">
<table class="pricing-table decision-table">
<thead>
<tr>
<th>{{ labels.benchmarkCompetitor }}</th>
<th>{{ labels.targetPrice }}</th>
<th>{{ labels.grossMarginRate }}</th>
<th>{{ labels.competitorDifferentiator }}</th>
<th>{{ labels.pricingReason }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<el-select v-model="pricingDecision.benchmarkCompetitor"
:placeholder="labels.benchmarkPlaceholder"
@change="clearValidation">
<el-option v-for="(row, index) in competitorRows" :key="row.id"
:label="competitorOptionLabel(row, index)"
:value="row.productType"
:disabled="!String(row.productType || '').trim()" />
</el-select>
</td>
<td>
<el-input v-model="pricingDecision.targetPrice" inputmode="decimal"
:placeholder="labels.pricePlaceholder" @input="clearValidation">
<template #append>{{ labels.yuan }}</template>
</el-input>
</td>
<td class="sale-price-cell">
<span v-if="grossMarginRate !== null">{{ formatMoney(grossMarginRate)
}}%</span>
<span v-else class="empty-price">{{ labels.waitForInput }}</span>
</td>
<td><el-input v-model="pricingDecision.differentiator" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" @input="clearValidation" />
</td>
<td><el-input v-model="pricingDecision.pricingReason" type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }" @input="clearValidation" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
<footer class="workbench-footer">
<p>{{ labels.competitorFooterTip }}</p>
<div class="footer-actions">
<el-button plain :disabled="saving" @click="resetStepTwo">{{ labels.clearStepTwo
}}</el-button>
</div>
</footer>
</template>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
@click="previousStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(currentStep).padStart(2, "0") }} / {{
String(pricingSteps.length).padStart(2, "0") }}</span>
<button v-if="currentStep < pricingSteps.length" type="button" class="btn-nav btn-primary"
:disabled="saving" @click="nextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
<button v-else type="button" class="btn-nav btn-primary" :disabled="saving"
@click="saveStepFive">
提交并完成实训
</button>
</div>
</div>
</section>
</section>
</main>
<TrainingAiSidebar :task-key="TASK_KEY" :has-saved-answer="hasSavedAnswer" :progress-items="progressItems"
:study-tip="labels.studyTip" :reference-title="labels.referenceTitle"
:reference-text="labels.referenceText" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, reactive, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import {
getStudentTrainingAnswer,
saveStudentTrainingAnswer,
uploadStudentTrainingFile,
validateProductPricingStepOne,
validateProductPricingStepTwo,
validateProductPricingStepThree,
validateProductPricingStepFour,
validateProductPricingStepFive,
} from "@/api/studentTrainingAnswer"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import { isStudentDemo } from "@/utils/studentDemo"
const TASK_KEY = "product-pricing"
const { proxy } = getCurrentInstance()
const labels = {
psmTitle: "PSM价格敏感度测试",
psmIntro: "PSM(价格敏感度测试)通过四个问题调查用户对价格的敏感程度,包括:太贵不买;太便宜不买(怀疑质量);偏高但可接受;便宜划算。请根据案例资料提供的PSM问卷原始数据,计算各价格区间四类回答的累计占比,计算价格敏感度。",
psmDataTitle: "(一)根据案例资料填写 PSM 累计占比表",
psmAddRow: "增加一行",
psmRemoveRow: "删除一行",
psmExport: "导出",
psmPriceRange: "价格(元)",
psmTooExpensive: "太贵不买累计占比(%)",
psmTooCheap: "太便宜不买累计占比(%)",
psmExpensiveAcceptable: "偏高可接受累计占比(%)",
psmCheapValue: "便宜划算累计占比(%)",
psmCustomRangePlaceholder: "填写价格区间",
psmPercentagePlaceholder: "0-100",
psmPricePointTitle: "(三)确定价格点",
psmOptimalPrice: "最优价格点",
psmAcceptablePrice: "可接受价格点",
psmAcceptableRange: "可接受价格范围",
psmPricePointPlaceholder: "学生手动填写",
psmChartTitle: "(二)PSM价格数据可视化",
psmChartHint: "根据上表,绘制PSM价格数据折线图:",
psmUploadChart: "上传PSM价格数据折线图",
psmChartUploadSuccess: "图表上传成功",
psmChartUploadError: "图表上传失败,请重试",
psmChartRemove: "移除图表",
psmSave: "保存PSM价格敏感度测试",
psmSaving: "保存中...",
psmSaveSuccess: "PSM价格敏感度测试已保存",
psmRowRequired: "请完整填写第 {index} 行 PSM 数据。",
psmPercentageRange: "第 {index} 行“{label}”累计占比应在 0%–100% 之间。",
psmPricePointsRequired: "请完整填写最优价格点、可接受价格点和可接受价格范围。",
finalPricingTitle: "最终量产定价",
finalPricingIntro: "汇总四种定价方法的测算结果,综合分析确定最终量产定价,并说明定价依据与利润空间。",
costMarkupChannel: "成本加成定价渠道",
costMarkupChannelHint: "从第 1 步已计算的渠道加成售价中选择一个作为成本加成定价结果。",
costMarkupChannelPlaceholder: "请选择第 1 步渠道售价",
pricingMethod: "定价方法",
measuredResult: "测算结果",
finalAnalysisTitle: "最终量产定价分析",
finalAnalysisPlaceholder: "请综合成本、竞品、用户感知价值和 PSM 价格敏感度,说明最终量产定价、定价依据与利润空间。",
finalPricingSave: "提交并完成实训",
finalPricingSaving: "提交中...",
finalPricingSaveSuccess: "实训任务提交成功",
finalPricingChannelRequired: "请选择用于汇总的成本加成定价渠道。",
finalPricingPrerequisiteRequired: "请先完成前四步定价测算,确保四种定价方法均有测算结果和毛利润率。",
finalPricingAnalysisRequired: "请填写最终量产定价分析。",
uploading: "上传中...",
pricingTitle: "\u6e20\u9053\u52a0\u6210\u5b9a\u4ef7",
formulaTip: "\u6838\u5fc3\u903b\u8f91\uff1a\u603b\u6210\u672c \u00d7\uff081 + \u52a0\u6210\u7387\uff09= \u6700\u7ec8\u552e\u4ef7\u3002\u4f01\u4e1a\u6839\u636e\u6e20\u9053\u7c7b\u578b\u548c\u54c1\u724c\u5b9a\u4f4d\u786e\u5b9a\u52a0\u6210\u7387\uff0c\u5728\u6210\u672c\u57fa\u7840\u4e0a\u52a0\u6210\u5b9a\u4ef7\u3002\u8bf7\u6839\u636e\u6848\u4f8b\u8d44\u6599\u63d0\u4f9b\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u8ba1\u7b97\u5404\u6e20\u9053\u52a0\u6210\u552e\u4ef7\u3002",
unitCost: "\u5355\u53f0\u7efc\u5408\u6210\u672c\uff08\u5143\uff09",
unitCostHelp: "\u7531\u5b66\u751f\u4f9d\u636e\u6848\u4f8b\u8d44\u6599\u5728\u672c\u9875\u586b\u5199\uff0c\u4ec5\u7528\u4e8e\u672c\u9875\u81ea\u52a8\u8ba1\u7b97\u3002",
unitCostPlaceholder: "\u8bf7\u8f93\u5165\u5355\u53f0\u7efc\u5408\u6210\u672c",
yuan: "\u5143",
yuanSign: "\u00a5",
serial: "\u5e8f\u53f7",
channel: "\u6e20\u9053",
markupRate: "\u52a0\u6210\u7387\uff08%\uff09",
salePrice: "\u52a0\u6210\u552e\u4ef7\uff08\u5143\uff09",
rangePrefix: "\u793a\u4f8b\u586b\u5199\u8303\u56f4\uff1a",
waitForInput: "\u7b49\u5f85\u586b\u5199",
footerTip: "\u586b\u5199\u5355\u53f0\u7efc\u5408\u6210\u672c\u548c\u4e09\u4e2a\u6e20\u9053\u7684\u52a0\u6210\u7387\u540e\uff0c\u7cfb\u7edf\u5c06\u81ea\u52a8\u8ba1\u7b97\u52a0\u6210\u552e\u4ef7\u3002",
clear: "\u6e05\u7a7a\u586b\u5199",
save: "\u4fdd\u5b58\u672c\u6b65\u9aa4",
studyTip: "\u5148\u6838\u5bf9\u6848\u4f8b\u4e2d\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u518d\u7ed3\u5408\u5404\u6e20\u9053\u5b9a\u4f4d\u586b\u5199\u52a0\u6210\u7387\uff1b\u552e\u4ef7\u4f1a\u6309\u516c\u5f0f\u81ea\u52a8\u8ba1\u7b97\u3002",
referenceTitle: "\u5b9a\u4ef7\u6838\u5bf9\u63d0\u793a",
referenceText: "\u8bf7\u68c0\u67e5\u5355\u53f0\u7efc\u5408\u6210\u672c\u662f\u5426\u6765\u81ea\u6848\u4f8b\u8d44\u6599\uff0c\u5e76\u786e\u8ba4\u4e09\u4e2a\u6e20\u9053\u7684\u52a0\u6210\u7387\u5747\u5728\u9875\u9762\u7ed9\u51fa\u7684\u5efa\u8bae\u8303\u56f4\u5185\u3002",
saveSuccess: "\u672c\u6b65\u9aa4\u5df2\u4fdd\u5b58",
resetSuccess: "\u5df2\u6e05\u7a7a\u672c\u9875\u586b\u5199\u5185\u5bb9",
costRequired: "\u8bf7\u586b\u5199\u5927\u4e8e 0 \u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u3002",
rateRequired: "\u8bf7\u586b\u5199\u201c{channel}\u201d\u7684\u52a0\u6210\u7387\u3002",
rateRange: "\u201c{channel}\u201d\u7684\u52a0\u6210\u7387\u5e94\u5728 {min}%\u2013{max}% \u4e4b\u95f4\u3002",
competitorPricingTitle: "\u7ade\u54c1\u4ef7\u683c\u5e26\u4e0e\u5b9a\u4ef7",
competitorIntro: "对标同品类竞品,根据产品差异化优势寻找价格空位。根据案例资料竞品信息,请分析竞品价格带与差异化定位,填写差异化优势,并确定本产品可切入的价格空位。",
competitorAnalysis: "\u7ade\u54c1\u5dee\u5f02\u5316\u4f18\u52bf\u53ca\u552e\u4ef7\u5206\u6790",
competitorProductType: "\u7ade\u54c1\u4ea7\u54c1\u7c7b\u578b",
competitorBrand: "\u54c1\u724c",
competitorEcommercePrice: "\u7535\u5546\u552e\u4ef7\uff08\u5143\uff09",
competitorDifferentiator: "\u5dee\u5f02\u5316\u4f18\u52bf",
brandPlaceholder: "\u8bf7\u586b\u5199\u54c1\u724c",
pricePlaceholder: "\u8bf7\u586b\u5199\u91d1\u989d",
differentiatorPlaceholder: "\u8bf7\u586b\u5199\u5dee\u5f02\u5316\u4f18\u52bf",
decisionTitle: "\u786e\u5b9a\u5bf9\u6807\u7ade\u54c1\u53ca\u5b9a\u4ef7",
benchmarkCompetitor: "\u5bf9\u6807\u7ade\u54c1",
benchmarkPlaceholder: "\u8bf7\u5148\u586b\u5199\u5e76\u9009\u62e9\u5bf9\u6807\u7ade\u54c1",
targetPrice: "\u5b9a\u4ef7\uff08\u5143\uff09",
grossMarginRate: "\u6bdb\u5229\u6da6\u7387",
pricingReason: "\u5b9a\u4ef7\u539f\u56e0\u5206\u6790",
competitorFooterTip: "\u7ade\u54c1\u4fe1\u606f\u548c\u5b9a\u4ef7\u539f\u56e0\u5747\u9700\u5b8c\u6574\u586b\u5199\uff1b\u6bdb\u5229\u6da6\u7387\u4f1a\u6839\u636e\u7b2c 1 \u6b65\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u81ea\u52a8\u8ba1\u7b97\u3002",
saveStepOne: "\u4fdd\u5b58\u5e76\u8fdb\u5165\u7b2c 2 \u6b65",
clearStepTwo: "\u6e05\u7a7a\u7b2c 2 \u6b65",
saveStepTwo: "\u4fdd\u5b58\u7b2c 2 \u6b65",
stepOneSaveSuccess: "\u7b2c 1 \u6b65\u5df2\u4fdd\u5b58\uff0c\u5df2\u8fdb\u5165\u7b2c 2 \u6b65",
stepTwoSaveSuccess: "\u7b2c 2 \u6b65\u5df2\u4fdd\u5b58",
stepTwoResetSuccess: "\u5df2\u6e05\u7a7a\u7b2c 2 \u6b65\u586b\u5199\u5185\u5bb9",
valueBasedPricingTitle: "用户感知价值定价",
valueBasedPricingIntro: "基于用户感知价值定价。将产品可替代的功能或产品逐一折算价值并加总,再按一定折扣让渡利润给用户,确定最终售价。",
valueBasedAddRow: "增加一行",
valueBasedRemoveRow: "删除一行",
perceivedValueItem: "可替代产品/功能/额外价值感知",
perceivedValueAmount: "价值感知(元)",
perceivedValueItemPlaceholder: "示例:闹钟",
perceivedValueAmountPlaceholder: "示例:20",
perceivedValueTotal: "感知价值合计",
profitDiscountRate: "利润让渡折扣率",
discountRatePlaceholder: "1-100",
valueBasedPrice: "价值定价法价格",
valueBasedPriceFormula: "价值定价法价格 = 感知价值合计 × 折扣率",
valueBasedFooterTip: "首行示例可修改但不可删除;可增加或删除中间行,合计与价值定价法价格将自动计算。",
clearStepThree: "清空第 3 步",
saveStepThree: "保存第 3 步",
stepThreeSaveSuccess: "第 3 步已保存",
stepThreeResetSuccess: "已清空第 3 步填写内容",
perceivedValueItemRequired: "请完整填写第 {index} 项价值感知。",
perceivedValueAmountRequired: "第 {index} 项价值感知金额必须大于 0。",
discountRateRequired: "利润让渡折扣率应大于 0 且不超过 100%。",
costRequiredForStepTwo: "\u8bf7\u5148\u5728\u7b2c 1 \u6b65\u586b\u5199\u5927\u4e8e 0 \u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\u3002",
competitorRequired: "\u8bf7\u5b8c\u6574\u586b\u5199\u7ade\u54c1 {index} \u7684\u4fe1\u606f\u3002",
competitorPriceRequired: "\u8bf7\u586b\u5199\u7ade\u54c1 {index} \u5927\u4e8e 0 \u7684\u7535\u5546\u552e\u4ef7\u3002",
benchmarkRequired: "\u8bf7\u586b\u5199\u5bf9\u6807\u7ade\u54c1\u3002",
targetPriceRequired: "\u8bf7\u586b\u5199\u5927\u4e8e 0 \u7684\u5b9a\u4ef7\u3002",
differentiatorRequired: "\u8bf7\u586b\u5199\u672c\u4ea7\u54c1\u7684\u5dee\u5f02\u5316\u4f18\u52bf\u3002",
pricingReasonRequired: "\u8bf7\u586b\u5199\u5b9a\u4ef7\u539f\u56e0\u5206\u6790\u3002",
}
const DEFAULT_ROWS = [
{ id: "dealer", channel: "\u5382\u5bb6\u7ed9\u7ecf\u9500\u5546", minRate: 15, maxRate: 25 },
{ id: "direct-retail", channel: "\u5382\u5bb6\u76f4\u63a5\u7535\u5546\u96f6\u552e", minRate: 40, maxRate: 60 },
{ id: "premium-brand", channel: "\u9ad8\u7aef\u5b9a\u4f4d\u54c1\u724c", minRate: 70, maxRate: 80 },
]
const taskConfig = ref(null)
const briefOpen = ref(true)
const saving = ref(false)
const hasSavedAnswer = ref(false)
const unitCost = ref("")
const pricingRows = ref(createPricingRows())
const validationMessage = ref("")
const currentStep = ref(1)
const competitorRows = ref(createCompetitorRows())
const pricingDecision = ref(createPricingDecision())
const DEFAULT_PERCEIVED_VALUE_ITEM = { id: "perceived-value-default", name: "闹钟", perceivedValue: "20" }
let perceivedValueItemSeed = 0
const perceivedValueItems = ref(createPerceivedValueItems())
const perceivedValueDiscountRate = ref("")
const DEFAULT_PSM_PRICE_RANGES = [
{ id: "psm-under-100", priceRange: "≤100元" },
{ id: "psm-100-150", priceRange: "100~150元" },
{ id: "psm-150-200", priceRange: "150~200元" },
{ id: "psm-200-250", priceRange: "200~250元" },
{ id: "psm-250-300", priceRange: "250~300元" },
{ id: "psm-over-300", priceRange: "≥300元" },
]
let psmRowSeed = 0
const psmRows = ref(createPsmRows())
const psmPricePoints = reactive({
optimalPrice: "",
acceptablePrice: "",
acceptableRange: "",
})
const psmChart = reactive({ url: "", name: "" })
const psmChartInput = ref(null)
const uploadingPsmChart = ref(false)
const psmChartPreviewList = computed(() => (psmChart.url ? [psmChart.url] : []))
const selectedCostMarkupChannel = ref("")
const finalPricingAnalysis = ref("")
const dynamicStepAnswers = ref({})
const canRemovePsmRow = computed(() => psmRows.value.some((row) => !row.isDefault))
const canRemovePerceivedValueItem = computed(() => perceivedValueItems.value.some((row) => !row.isDefault))
const DEFAULT_PRICING_STEPS = [labels.pricingTitle, labels.competitorPricingTitle, labels.valueBasedPricingTitle, labels.psmTitle, labels.finalPricingTitle]
const pricingSteps = computed(() => {
const configured = parseTrainingArray(taskConfig.value?.steps)
const names = configured.length ? configured.slice(0, DEFAULT_PRICING_STEPS.length) : DEFAULT_PRICING_STEPS
while (names.length < DEFAULT_PRICING_STEPS.length) names.push(DEFAULT_PRICING_STEPS[names.length])
return names.map((title, index) => ({ no: index + 1, title }))
})
const currentStepTitle = computed(() => pricingSteps.value[currentStep.value - 1]?.title || labels.pricingTitle)
const taskTitle = computed(() => taskConfig.value?.taskName || "\u65b0\u4ea7\u54c1\u5b9a\u4ef7")
const taskBackground = computed(
() =>
taskConfig.value?.background ||
"\u57fa\u4e8e\u6848\u4f8b\u4e2d\u7684\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u7ed3\u5408\u6e20\u9053\u7c7b\u578b\u548c\u54c1\u724c\u5b9a\u4f4d\uff0c\u5b8c\u6210\u4e0d\u540c\u6e20\u9053\u7684\u52a0\u6210\u5b9a\u4ef7\u3002",
)
const taskRequirement = computed(
() =>
taskConfig.value?.requirements ||
"\u586b\u5199\u5355\u53f0\u7efc\u5408\u6210\u672c\uff0c\u5e76\u4e3a\u4e09\u79cd\u6e20\u9053\u586b\u5199\u5efa\u8bae\u8303\u56f4\u5185\u7684\u52a0\u6210\u7387\uff0c\u7cfb\u7edf\u81ea\u52a8\u8ba1\u7b97\u52a0\u6210\u552e\u4ef7\u3002",
)
const taskGoals = computed(() =>
parseTrainingArray(taskConfig.value?.objectives, [
"\u7406\u89e3\u6210\u672c\u52a0\u6210\u5b9a\u4ef7\u7684\u57fa\u672c\u903b\u8f91",
"\u533a\u5206\u4e0d\u540c\u6e20\u9053\u7684\u52a0\u6210\u7b56\u7565",
"\u5b8c\u6210\u4e09\u7c7b\u6e20\u9053\u7684\u552e\u4ef7\u8ba1\u7b97",
]),
)
const progressItems = computed(() => [{ text: "\u65b0\u4ea7\u54c1\u5b9a\u4ef7", status: "doing" }])
const grossMarginRate = computed(() => {
const cost = parseNumber(unitCost.value)
const price = parseNumber(pricingDecision.value.targetPrice)
if (cost === null || cost <= 0 || price === null || price <= 0) return null
return ((price - cost) / price) * 100
})
const perceivedValueTotal = computed(() => {
const values = perceivedValueItems.value.map((row) => parseNumber(row.perceivedValue))
if (values.some((value) => value === null || value <= 0)) return null
return values.reduce((total, value) => total + value, 0)
})
const valueBasedPrice = computed(() => {
const discountRate = parseNumber(perceivedValueDiscountRate.value)
if (perceivedValueTotal.value === null || discountRate === null || discountRate <= 0 || discountRate > 100) return null
return perceivedValueTotal.value * discountRate / 100
})
const costMarkupChannelOptions = computed(() =>
pricingRows.value.map((row) => {
const price = salePrice(row)
return {
value: row.channel,
label: price === null ? `${row.channel}(待完成第 1 步)` : `${row.channel}(${labels.yuanSign} ${formatMoney(price)})`,
disabled: price === null,
}
}),
)
const stepFiveMethodResults = computed(() => {
const unitCostValue = parseNumber(unitCost.value)
const selectedCostMarkupRow = pricingRows.value.find((row) => row.channel === selectedCostMarkupChannel.value)
const results = [
{ key: "cost-markup", methodName: "成本加成定价", measuredPrice: selectedCostMarkupRow ? salePrice(selectedCostMarkupRow) : null },
{ key: "competitive", methodName: "竞争定价", measuredPrice: parseNumber(pricingDecision.value.targetPrice) },
{ key: "value-based", methodName: "价值定价", measuredPrice: valueBasedPrice.value },
{ key: "psm", methodName: "PSM价格敏感度", measuredPrice: parseNumber(psmPricePoints.optimalPrice) },
]
return results.map((result) => ({
...result,
grossMarginRate: unitCostValue === null || unitCostValue <= 0 || result.measuredPrice === null || result.measuredPrice <= 0
? null
: ((result.measuredPrice - unitCostValue) / result.measuredPrice) * 100,
}))
})
function createPricingRows() {
return DEFAULT_ROWS.map((row) => ({ ...row, markupRate: "" }))
}
function createCompetitorRows() {
return Array.from({ length: 4 }, (_, index) => ({
id: `competitor-${index + 1}`,
label: `\u7ade\u54c1${index + 1}`,
productType: "",
brand: "",
ecommercePrice: "",
differentiator: "",
}))
}
function createPricingDecision() {
return {
benchmarkCompetitor: "",
targetPrice: "",
grossMarginRate: null,
differentiator: "",
pricingReason: "",
}
}
function createPerceivedValueItem(row = {}) {
return {
id: row.id || `perceived-value-custom-${++perceivedValueItemSeed}`,
name: row.name ?? "",
perceivedValue: row.perceivedValue ?? "",
isDefault: Boolean(row.isDefault),
}
}
function createPerceivedValueItems(savedItems = []) {
const sourceItems = Array.isArray(savedItems) ? savedItems : []
const defaultItem = createPerceivedValueItem({
...DEFAULT_PERCEIVED_VALUE_ITEM,
...sourceItems.find((item) => item?.id === DEFAULT_PERCEIVED_VALUE_ITEM.id),
isDefault: true,
})
const customItems = sourceItems
.filter((item) => item && item.id !== DEFAULT_PERCEIVED_VALUE_ITEM.id)
.map((item) => createPerceivedValueItem({ ...item, isDefault: false }))
return [defaultItem, ...customItems]
}
function addPerceivedValueItem() {
perceivedValueItems.value.push(createPerceivedValueItem())
}
function removePerceivedValueItem() {
for (let index = perceivedValueItems.value.length - 1; index >= 0; index -= 1) {
if (!perceivedValueItems.value[index].isDefault) {
perceivedValueItems.value.splice(index, 1)
return
}
}
}
function createPsmRow(row = {}) {
return {
id: row.id || `psm-custom-${++psmRowSeed}`,
priceRange: row.priceRange || "",
isDefault: Boolean(row.isDefault),
tooExpensive: row.tooExpensive ?? "",
tooCheap: row.tooCheap ?? "",
expensiveAcceptable: row.expensiveAcceptable ?? "",
cheapValue: row.cheapValue ?? "",
}
}
function createPsmRows(savedRows = []) {
const sourceRows = Array.isArray(savedRows) ? savedRows : []
const defaultRows = DEFAULT_PSM_PRICE_RANGES.map((row) =>
createPsmRow({
...row,
...sourceRows.find((item) => item?.id === row.id),
isDefault: true,
})
)
const customRows = sourceRows
.filter((row) => row && !DEFAULT_PSM_PRICE_RANGES.some((item) => item.id === row.id))
.map((row) => createPsmRow({ ...row, isDefault: false }))
return [...defaultRows, ...customRows]
}
function addPsmRow() {
psmRows.value.push(createPsmRow())
}
function removePsmRow() {
for (let index = psmRows.value.length - 1; index >= 0; index -= 1) {
if (!psmRows.value[index].isDefault) {
psmRows.value.splice(index, 1)
return
}
}
}
function buildStepFourProgress() {
return {
psmRows: psmRows.value.map((row) => ({ ...row })),
pricePoints: { ...psmPricePoints },
chart: { ...psmChart },
}
}
function applyStepFourProgress(progress) {
if (!progress || typeof progress !== "object") return
psmRows.value = createPsmRows(progress.psmRows)
Object.assign(
psmPricePoints,
{ optimalPrice: "", acceptablePrice: "", acceptableRange: "" },
progress.pricePoints || {}
)
Object.assign(psmChart, { url: "", name: "" }, progress.chart || {})
}
function buildStepFiveProgress() {
return {
selectedCostMarkupChannel: selectedCostMarkupChannel.value,
methodResults: stepFiveMethodResults.value.map((result) => ({ ...result })),
finalAnalysis: finalPricingAnalysis.value,
updatedAt: new Date().toISOString(),
}
}
function applyStepFiveProgress(progress) {
if (!progress || typeof progress !== "object") return
selectedCostMarkupChannel.value = progress.selectedCostMarkupChannel ?? ""
finalPricingAnalysis.value = progress.finalAnalysis ?? ""
}
function triggerPsmChartUpload() {
psmChartInput.value?.click()
}
async function handlePsmChartUpload(event) {
const file = event?.target?.files?.[0]
if (event?.target) event.target.value = ""
if (!file) return
uploadingPsmChart.value = true
try {
let url = ""
if (isStudentDemo()) {
url = URL.createObjectURL(file)
} else {
const formData = new FormData()
formData.append("file", file)
const response = await uploadStudentTrainingFile(formData)
url = response?.url || response?.data?.url || ""
}
if (!url) throw new Error(labels.psmChartUploadError)
Object.assign(psmChart, { url, name: file.name })
proxy?.$modal?.msgSuccess?.(labels.psmChartUploadSuccess)
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || labels.psmChartUploadError)
} finally {
uploadingPsmChart.value = false
}
}
function removePsmChart() {
Object.assign(psmChart, { url: "", name: "" })
}
function formatPsmCsvCell(value) {
return `"${String(value ?? "").replace(/"/g, '""')}"`
}
function exportPsmTable() {
const rows = [
[
labels.psmPriceRange,
labels.psmTooExpensive,
labels.psmTooCheap,
labels.psmExpensiveAcceptable,
labels.psmCheapValue,
],
...psmRows.value.map((row) => [
row.priceRange,
row.tooExpensive,
row.tooCheap,
row.expensiveAcceptable,
row.cheapValue,
]),
[],
[labels.psmOptimalPrice, psmPricePoints.optimalPrice],
[labels.psmAcceptablePrice, psmPricePoints.acceptablePrice],
[labels.psmAcceptableRange, psmPricePoints.acceptableRange],
]
const blob = new Blob(
[`\ufeff${rows.map((row) => row.map(formatPsmCsvCell).join(",")).join("\n")}`],
{ type: "text/csv;charset=utf-8;" }
)
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = "PSM价格敏感度测试.csv"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function exportPriceOutcome() {
const payload = {
task: taskTitle.value,
exportedAt: new Date().toISOString(),
stepOne: buildStepOneProgress(),
stepTwo: buildStepTwoProgress(),
stepThree: buildStepThreeProgress(),
stepFour: buildStepFourProgress(),
stepFive: buildStepFiveProgress(),
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `${taskTitle.value}-实训成果.json`
link.click()
URL.revokeObjectURL(url)
}
function validateStepFour() {
for (const [index, row] of psmRows.value.entries()) {
if (isBlank(row.id) || isBlank(row.priceRange)) {
return interpolate(labels.psmRowRequired, { index: index + 1 })
}
for (const [field, label] of [["tooExpensive", "太贵不买"], ["tooCheap", "太便宜不买"], ["expensiveAcceptable", "偏高可接受"], ["cheapValue", "便宜划算"]]) {
const value = parseNumber(row[field])
if (value === null || value < 0 || value > 100) {
return interpolate(labels.psmPercentageRange, { index: index + 1, label })
}
}
}
const optimalPrice = parseNumber(psmPricePoints.optimalPrice)
const acceptablePrice = parseNumber(psmPricePoints.acceptablePrice)
if (optimalPrice === null || optimalPrice <= 0 || acceptablePrice === null || acceptablePrice <= 0 || isBlank(psmPricePoints.acceptableRange)) {
return labels.psmPricePointsRequired
}
return ""
}
async function saveStepFour() {
if (saving.value) return false
const message = validateStepFour()
validationMessage.value = message
if (message) {
proxy?.$modal?.msgWarning?.(message)
return false
}
saving.value = true
try {
const validationResponse = await validateProductPricingStepFour({
psmRows: psmRows.value.map((row) => ({
id: row.id,
priceRange: row.priceRange,
tooExpensive: parseNumber(row.tooExpensive),
tooCheap: parseNumber(row.tooCheap),
expensiveAcceptable: parseNumber(row.expensiveAcceptable),
cheapValue: parseNumber(row.cheapValue),
})),
pricePoints: {
optimalPrice: parseNumber(psmPricePoints.optimalPrice),
acceptablePrice: parseNumber(psmPricePoints.acceptablePrice),
acceptableRange: psmPricePoints.acceptableRange,
},
})
const validated = validationResponse?.data || validationResponse || {}
if (Array.isArray(validated.psmRows)) {
psmRows.value = createPsmRows(validated.psmRows)
}
if (validated.pricePoints) {
Object.assign(psmPricePoints, validated.pricePoints)
}
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 4,
step4Answer: JSON.stringify(buildStepFourProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.psmSaveSuccess)
return true
} finally {
saving.value = false
}
}
function validateStepFive() {
if (isBlank(selectedCostMarkupChannel.value)) return labels.finalPricingChannelRequired
if (stepFiveMethodResults.value.some((result) => result.measuredPrice === null || result.measuredPrice <= 0 || result.grossMarginRate === null)) {
return labels.finalPricingPrerequisiteRequired
}
if (isBlank(finalPricingAnalysis.value)) return labels.finalPricingAnalysisRequired
return ""
}
async function saveStepFive() {
if (saving.value) return
const message = validateStepFive()
validationMessage.value = message
if (message) {
proxy?.$modal?.msgWarning?.(message)
return
}
saving.value = true
try {
const validationResponse = await validateProductPricingStepFive({
selectedCostMarkupChannel: selectedCostMarkupChannel.value,
methodResults: stepFiveMethodResults.value.map((result) => ({
key: result.key,
methodName: result.methodName,
measuredPrice: result.measuredPrice,
grossMarginRate: result.grossMarginRate,
})),
finalAnalysis: finalPricingAnalysis.value,
})
const validated = validationResponse?.data || validationResponse || {}
selectedCostMarkupChannel.value = validated.selectedCostMarkupChannel ?? selectedCostMarkupChannel.value
finalPricingAnalysis.value = validated.finalAnalysis ?? finalPricingAnalysis.value
dynamicStepAnswers.value = { ...dynamicStepAnswers.value, step5: buildStepFiveProgress() }
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SUBMIT",
currentStep: 5,
submitted: true,
dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.finalPricingSaveSuccess)
} finally {
saving.value = false
}
}
function parseNumber(value) {
if (value === null || value === undefined || String(value).trim() === "") {
return null
}
const normalized = String(value).trim().replace(/[%\uff0c,]/g, "")
const number = Number(normalized)
return Number.isFinite(number) ? number : null
}
function salePrice(row) {
const cost = parseNumber(unitCost.value)
const rate = parseNumber(row.markupRate)
if (cost === null || cost < 0 || rate === null || rate < 0) {
return null
}
return cost * (1 + rate / 100)
}
function formatMoney(value) {
return Number(value).toFixed(2)
}
function ratePlaceholder(row) {
return `${row.minRate}%-${row.maxRate}%`
}
function rateHint(row) {
return `${labels.rangePrefix}${row.minRate}%-${row.maxRate}%`
}
function clearValidation() {
validationMessage.value = ""
}
function interpolate(template, values) {
return Object.entries(values).reduce(
(result, [key, value]) => result.replace(`{${key}}`, value),
template,
)
}
function validateStepOne() {
const cost = parseNumber(unitCost.value)
if (cost === null || cost <= 0) {
return labels.costRequired
}
for (const row of pricingRows.value) {
const rate = parseNumber(row.markupRate)
if (rate === null) {
return interpolate(labels.rateRequired, { channel: row.channel })
}
if (rate < row.minRate || rate > row.maxRate) {
return interpolate(labels.rateRange, {
channel: row.channel,
min: row.minRate,
max: row.maxRate,
})
}
}
return ""
}
function buildStepOneProgress() {
return {
version: 1,
unitCost: unitCost.value,
pricingRows: pricingRows.value.map((row) => ({
id: row.id,
markupRate: row.markupRate,
})),
updatedAt: new Date().toISOString(),
}
}
function applyStepOneProgress(progress) {
if (!progress || typeof progress !== "object") {
return
}
unitCost.value = progress.unitCost ?? ""
const savedRows = new Map(
Array.isArray(progress.pricingRows)
? progress.pricingRows.map((row) => [row?.id, row])
: [],
)
pricingRows.value = DEFAULT_ROWS.map((row) => ({
...row,
markupRate: savedRows.get(row.id)?.markupRate ?? "",
}))
}
function buildStepTwoProgress() {
return {
version: 1,
competitorRows: competitorRows.value.map((row) => ({
id: row.id,
productType: row.productType,
brand: row.brand,
ecommercePrice: row.ecommercePrice,
differentiator: row.differentiator,
})),
pricingDecision: { ...pricingDecision.value },
updatedAt: new Date().toISOString(),
}
}
function applyStepTwoProgress(progress) {
if (!progress || typeof progress !== "object") {
return
}
const savedRows = new Map(
Array.isArray(progress.competitorRows)
? progress.competitorRows.map((row) => [row?.id, row])
: [],
)
competitorRows.value = createCompetitorRows().map((row) => {
const saved = savedRows.get(row.id) || {}
return {
...row,
productType: saved.productType ?? "",
brand: saved.brand ?? "",
ecommercePrice: saved.ecommercePrice ?? "",
differentiator: saved.differentiator ?? "",
}
})
pricingDecision.value = {
...createPricingDecision(),
...(progress.pricingDecision || {}),
}
}
function buildStepThreeProgress() {
return {
version: 1,
valueItems: perceivedValueItems.value.map((row) => ({
id: row.id,
name: row.name,
perceivedValue: row.perceivedValue,
})),
discountRate: perceivedValueDiscountRate.value,
perceivedValueTotal: perceivedValueTotal.value,
valueBasedPrice: valueBasedPrice.value,
updatedAt: new Date().toISOString(),
}
}
function applyStepThreeProgress(progress) {
if (!progress || typeof progress !== "object") return
perceivedValueItems.value = createPerceivedValueItems(progress.valueItems)
perceivedValueDiscountRate.value = progress.discountRate ?? ""
}
function isBlank(value) {
return !String(value ?? "").trim()
}
function competitorOptionLabel(row, index) {
const productType = String(row?.productType || "").trim()
const brand = String(row?.brand || "").trim()
return productType ? `竞品${index + 1}:${productType}${brand ? `(${brand})` : ""}` : `竞品${index + 1}:请先填写产品类型`
}
function validateStepTwo() {
const cost = parseNumber(unitCost.value)
if (cost === null || cost <= 0) {
return labels.costRequiredForStepTwo
}
for (const [index, row] of competitorRows.value.entries()) {
if (isBlank(row.productType) || isBlank(row.brand) || isBlank(row.differentiator)) {
return interpolate(labels.competitorRequired, { index: index + 1 })
}
const ecommercePrice = parseNumber(row.ecommercePrice)
if (ecommercePrice === null || ecommercePrice <= 0) {
return interpolate(labels.competitorPriceRequired, { index: index + 1 })
}
}
if (isBlank(pricingDecision.value.benchmarkCompetitor)) {
return labels.benchmarkRequired
}
const targetPrice = parseNumber(pricingDecision.value.targetPrice)
if (targetPrice === null || targetPrice <= 0) {
return labels.targetPriceRequired
}
if (isBlank(pricingDecision.value.differentiator)) {
return labels.differentiatorRequired
}
if (isBlank(pricingDecision.value.pricingReason)) {
return labels.pricingReasonRequired
}
return ""
}
function validateStepThree() {
for (const [index, row] of perceivedValueItems.value.entries()) {
if (isBlank(row.name)) {
return interpolate(labels.perceivedValueItemRequired, { index: index + 1 })
}
const perceivedValue = parseNumber(row.perceivedValue)
if (perceivedValue === null || perceivedValue <= 0) {
return interpolate(labels.perceivedValueAmountRequired, { index: index + 1 })
}
}
const discountRate = parseNumber(perceivedValueDiscountRate.value)
if (discountRate === null || discountRate <= 0 || discountRate > 100) {
return labels.discountRateRequired
}
return ""
}
function parseSavedJson(raw) {
if (!raw) {
return null
}
try {
return typeof raw === "string" ? JSON.parse(raw) : raw
} catch (error) {
return null
}
}
function switchStep(step) {
currentStep.value = step
clearValidation()
}
function previousStep() {
if (currentStep.value > 1) switchStep(currentStep.value - 1)
}
function saveCurrentProgress() {
if (currentStep.value === 1) return saveStepOne(false)
if (currentStep.value === 2) return saveStepTwo()
if (currentStep.value === 3) return saveStepThree()
if (currentStep.value === 4) return saveStepFour()
return saveStepFiveDraft()
}
async function nextStep() {
const target = currentStep.value + 1
if (target > pricingSteps.value.length) return
if (currentStep.value === 1) {
await saveStepOne(true)
return
}
let saved = false
try {
if (currentStep.value === 2) saved = (await saveStepTwo()) === true
else if (currentStep.value === 3) saved = (await saveStepThree()) === true
else if (currentStep.value === 4) saved = (await saveStepFour()) === true
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "保存失败,请检查填写内容")
return
}
if (saved) switchStep(target)
}
async function saveStepFiveDraft() {
if (saving.value) return
saving.value = true
try {
dynamicStepAnswers.value = { ...dynamicStepAnswers.value, step5: buildStepFiveProgress() }
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 5,
dynamicStepAnswers: JSON.stringify(dynamicStepAnswers.value),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.("当前步骤已保存")
} finally {
saving.value = false
}
}
async function saveStepOne(advance = false) {
if (saving.value) {
return
}
const message = validateStepOne()
validationMessage.value = message
if (message) {
proxy?.$modal?.msgWarning?.(message)
return
}
saving.value = true
try {
const validationResponse = await validateProductPricingStepOne({
unitCost: parseNumber(unitCost.value),
pricingRows: pricingRows.value.map((row) => ({ id: row.id, markupRate: parseNumber(row.markupRate) })),
})
const validated = validationResponse?.data || validationResponse || {}
if (validated.unitCost !== undefined && validated.unitCost !== null) {
unitCost.value = String(validated.unitCost)
}
const validatedRows = new Map((validated.pricingRows || []).map((row) => [row?.id, row]))
pricingRows.value = pricingRows.value.map((row) => ({
...row,
markupRate: validatedRows.get(row.id)?.markupRate ?? row.markupRate,
}))
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: advance ? 2 : 1,
step1Answer: JSON.stringify(buildStepOneProgress()),
})
hasSavedAnswer.value = true
if (advance) {
currentStep.value = 2
proxy?.$modal?.msgSuccess?.(labels.stepOneSaveSuccess)
} else {
proxy?.$modal?.msgSuccess?.(labels.saveSuccess)
}
} finally {
saving.value = false
}
}
async function resetStepOne() {
if (saving.value) {
return
}
unitCost.value = ""
pricingRows.value = createPricingRows()
validationMessage.value = ""
saving.value = true
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "RESET",
currentStep: 1,
step1Answer: JSON.stringify(buildStepOneProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.resetSuccess)
} finally {
saving.value = false
}
}
async function saveStepTwo() {
if (saving.value) {
return false
}
const message = validateStepTwo()
validationMessage.value = message
if (message) {
proxy?.$modal?.msgWarning?.(message)
return false
}
saving.value = true
try {
const validationResponse = await validateProductPricingStepTwo({
unitCost: parseNumber(unitCost.value),
competitorRows: competitorRows.value.map((row) => ({ id: row.id, productType: row.productType, brand: row.brand, ecommercePrice: parseNumber(row.ecommercePrice), differentiator: row.differentiator })),
decision: { ...pricingDecision.value, targetPrice: parseNumber(pricingDecision.value.targetPrice) },
})
const validated = validationResponse?.data || validationResponse || {}
const validatedRows = new Map((validated.competitorRows || []).map((row) => [row?.id, row]))
competitorRows.value = competitorRows.value.map((row) => ({ ...row, ...(validatedRows.get(row.id) || {}) }))
pricingDecision.value = { ...pricingDecision.value, ...(validated.decision || {}) }
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 2,
step2Answer: JSON.stringify(buildStepTwoProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.stepTwoSaveSuccess)
return true
} finally {
saving.value = false
}
}
async function resetStepTwo() {
if (saving.value) {
return
}
competitorRows.value = createCompetitorRows()
pricingDecision.value = createPricingDecision()
validationMessage.value = ""
saving.value = true
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 2,
step2Answer: JSON.stringify(buildStepTwoProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.stepTwoResetSuccess)
} finally {
saving.value = false
}
}
async function saveStepThree() {
if (saving.value) return false
const message = validateStepThree()
validationMessage.value = message
if (message) {
proxy?.$modal?.msgWarning?.(message)
return false
}
saving.value = true
try {
const validationResponse = await validateProductPricingStepThree({
valueItems: perceivedValueItems.value.map((row) => ({
id: row.id,
name: row.name,
perceivedValue: parseNumber(row.perceivedValue),
})),
discountRate: parseNumber(perceivedValueDiscountRate.value),
})
const validated = validationResponse?.data || validationResponse || {}
if (Array.isArray(validated.valueItems)) {
perceivedValueItems.value = createPerceivedValueItems(validated.valueItems)
}
if (validated.discountRate !== undefined && validated.discountRate !== null) {
perceivedValueDiscountRate.value = String(validated.discountRate)
}
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 3,
step3Answer: JSON.stringify(buildStepThreeProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.stepThreeSaveSuccess)
return true
} finally {
saving.value = false
}
}
async function resetStepThree() {
if (saving.value) return
perceivedValueItems.value = createPerceivedValueItems()
perceivedValueDiscountRate.value = ""
validationMessage.value = ""
saving.value = true
try {
await saveStudentTrainingAnswer(TASK_KEY, {
saveAction: "SAVE",
currentStep: 3,
step3Answer: JSON.stringify(buildStepThreeProgress()),
})
hasSavedAnswer.value = true
proxy?.$modal?.msgSuccess?.(labels.stepThreeResetSuccess)
} finally {
saving.value = false
}
}
async function loadTaskConfig() {
try {
const response = await getTrainingTaskByKey(TASK_KEY)
taskConfig.value = response?.data || response || null
} catch (error) {
taskConfig.value = null
}
}
async function loadSavedProgress() {
try {
const response = await getStudentTrainingAnswer(TASK_KEY)
const answer = response?.data || response || {}
const stepOneProgress = parseSavedJson(answer?.step1Answer)
const stepTwoProgress = parseSavedJson(answer?.step2Answer)
const stepThreeProgress = parseSavedJson(answer?.step3Answer)
const stepFourProgress = parseSavedJson(answer?.step4Answer)
dynamicStepAnswers.value = parseSavedJson(answer?.dynamicStepAnswers) || {}
const stepFiveProgress = dynamicStepAnswers.value?.step5
hasSavedAnswer.value = Boolean(stepOneProgress || stepTwoProgress || stepThreeProgress || stepFourProgress || stepFiveProgress)
if (stepOneProgress) {
applyStepOneProgress(stepOneProgress)
}
if (stepTwoProgress) {
applyStepTwoProgress(stepTwoProgress)
currentStep.value = 2
}
if (stepThreeProgress) {
applyStepThreeProgress(stepThreeProgress)
currentStep.value = 3
}
if (stepFourProgress) {
applyStepFourProgress(stepFourProgress)
currentStep.value = 4
}
if (stepFiveProgress) {
applyStepFiveProgress(stepFiveProgress)
currentStep.value = 5
}
} catch (error) {
hasSavedAnswer.value = false
}
}
onMounted(async () => {
await Promise.all([loadTaskConfig(), loadSavedProgress()])
})
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.price-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);
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
/* 不设 grid-template-columns,由全局 .student-training-shell 单列布局接管 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 对齐 foundation 的 .training-core:只设 gap,无边框/背景/阴影 */
.price-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* 任务头:独立白卡片(对齐 foundation 的 .task-header) */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
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-header 内部主列(对齐 foundation 的 .task-head-main) */
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.task-brief-text {
display: block;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b {
color: var(--student-text);
font-size: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
// 让 TrainingTaskToolbar 内部按钮在横条内右对齐
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* workbench:透明容器,去掉旧 border/bg */
.price-workbench {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* step-card 层:透明容器,去掉旧的 border/bg */
.step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.section-eyebrow {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
margin: 0;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.intro-copy {
margin: 14px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 按钮 ===== */
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font: inherit;
font-size: var(--student-fs-body);
font-weight: 700;
cursor: pointer;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
}
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-body);
font-weight: 700;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease;
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
}
}
.text-button {
padding: 0;
border: 0;
color: var(--student-accent);
background: transparent;
font: inherit;
font-size: var(--student-fs-body-sm);
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
color: var(--student-accent-deep);
}
}
.toolbar-actions {
display: flex;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* ===== 表格 ===== */
.pricing-table-wrap,
.table-scroll {
overflow-x: auto;
}
.pricing-table,
.price-table,
.psm-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.pricing-table th,
.pricing-table td,
.price-table th,
.price-table td,
.psm-table th,
.psm-table td {
padding: 10px 12px;
border: 1px solid var(--student-panel-border);
text-align: left;
}
.pricing-table th,
.price-table th,
.psm-table th {
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
}
.serial-cell {
color: var(--student-text-secondary);
font-weight: 700;
}
.channel-cell {
color: var(--student-text);
font-weight: 600;
}
.sale-price-cell {
color: var(--student-accent);
font-size: 18px;
font-weight: 700;
}
.empty-price {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
font-weight: 400;
}
.range-hint {
display: block;
margin-top: 6px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
}
.validation-message {
margin: 14px 0 0;
color: var(--student-danger);
line-height: 1.5;
}
.subsection-title {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
span {
display: grid;
width: 22px;
height: 22px;
place-items: center;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 700;
background: var(--student-accent-soft);
border-radius: 6px;
}
h3 {
margin: 0;
font-size: var(--student-fs-h2);
color: var(--student-text);
}
}
/* ===== 面板容器 ===== */
.unit-cost-panel,
.value-pricing-result,
.final-pricing-channel,
.psm-point-section,
.psm-chart-section,
.psm-chart-preview {
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.unit-cost-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 0 14px;
padding: 16px 18px;
}
.cost-panel-copy {
display: flex;
align-items: flex-start;
gap: 12px;
p {
margin: 0 0 4px;
font-size: var(--student-fs-h2);
font-weight: 700;
color: var(--student-text);
}
small {
color: var(--student-text-secondary);
line-height: 1.55;
}
}
.cost-dot {
width: 10px;
height: 10px;
margin-top: 7px;
border-radius: 50%;
background: var(--student-accent);
}
.unit-cost-input {
width: 270px;
}
.markup-input {
width: 190px;
}
.competitor-section {
margin-bottom: 14px;
}
.competitor-table {
min-width: 900px;
}
.decision-table {
min-width: 1160px;
}
.competitor-table td,
.decision-table td {
vertical-align: top;
}
.perceived-value-table {
min-width: 620px;
th,
td {
width: 50%;
}
.total-row td {
color: var(--student-text);
font-weight: 700;
background: var(--student-accent-soft);
}
}
.value-pricing-section {
display: grid;
gap: 14px;
}
.value-pricing-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
p {
max-width: 760px;
margin: 0;
color: var(--student-text-secondary);
line-height: 1.6;
font-size: var(--student-fs-body);
}
}
.value-pricing-result {
display: grid;
grid-template-columns: minmax(230px, 0.8fr) minmax(220px, 0.7fr) minmax(290px, 1fr);
align-items: center;
gap: 20px;
padding: 18px 22px;
label {
display: grid;
gap: 8px;
color: var(--student-text);
font-weight: 600;
font-size: var(--student-fs-body-sm);
}
p {
margin: 0;
color: var(--student-text-secondary);
line-height: 1.55;
font-size: var(--student-fs-body-sm);
}
}
.value-price-output {
display: grid;
gap: 6px;
color: var(--student-text);
font-weight: 600;
strong {
color: var(--student-accent);
font-size: 22px;
font-weight: 700;
}
em {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
font-style: normal;
font-weight: 400;
}
}
/* ===== 工作台底部 ===== */
.workbench-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 14px;
padding: 14px 18px;
border-top: 1px solid var(--student-panel-border);
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.55;
}
}
.footer-actions {
display: flex;
flex: none;
gap: 10px;
}
/* ===== Element Plus 输入换肤 ===== */
.price-page :deep(.el-input__wrapper) {
background: #ffffff;
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
border-radius: 8px;
}
.price-page :deep(.el-input__inner) {
color: var(--student-text);
}
.price-page :deep(.el-textarea__inner) {
color: var(--student-text);
background: #ffffff;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
font-family: inherit;
line-height: 1.55;
}
.price-page :deep(.el-textarea__inner:focus) {
outline: 2px solid rgba(31, 41, 55, 0.2);
outline-offset: -2px;
border-color: var(--student-accent);
}
.price-page :deep(.el-input-group__append) {
color: var(--student-accent);
border-left-color: var(--student-panel-border);
background: var(--student-accent-soft);
box-shadow: none;
}
/* ===== Final Pricing ===== */
.final-pricing-section {
display: grid;
gap: 14px;
}
.final-pricing-intro {
margin-bottom: 0;
}
.final-pricing-channel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0;
padding: 14px 18px;
h3,
p {
margin: 0;
}
h3 {
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
p {
margin-top: 4px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
:deep(.el-select) {
width: min(360px, 100%);
}
}
.final-pricing-table {
min-width: 680px;
}
.final-pricing-table td:nth-child(2),
.final-pricing-table td:nth-child(3) {
color: var(--student-accent);
font-weight: 700;
}
.final-analysis-section h3 {
margin: 0 0 12px;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
/* ===== PSM Section ===== */
.psm-section {
display: grid;
gap: 14px;
}
.psm-toolbar,
.psm-chart-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.psm-toolbar p,
.psm-chart-head p,
.psm-guide-row p {
margin: 0;
color: var(--student-text-secondary);
line-height: 1.6;
font-size: var(--student-fs-body);
}
.psm-subtitle {
margin-top: 8px !important;
color: var(--student-text) !important;
font-weight: 700;
font-size: var(--student-fs-body-sm);
}
.table-scroll {
border: 1px solid var(--student-panel-border);
border-radius: 10px;
}
.psm-table {
min-width: 980px;
td {
min-width: 150px;
color: var(--student-text);
}
td:first-child {
min-width: 120px;
font-weight: 700;
}
}
.psm-table input,
.psm-point-grid input {
box-sizing: border-box;
width: 100%;
min-height: 38px;
padding: 8px 10px;
color: var(--student-text);
background: #ffffff;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
outline: none;
font-family: inherit;
font-size: var(--student-fs-body);
&:focus {
border-color: var(--student-accent);
outline: 2px solid rgba(31, 41, 55, 0.2);
outline-offset: -2px;
}
}
.psm-point-section,
.psm-chart-section {
padding: 18px 20px;
}
.psm-point-section h3,
.psm-chart-section h3 {
margin: 0 0 14px;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
.psm-point-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.psm-point-grid label {
display: grid;
gap: 6px;
color: var(--student-text);
font-weight: 600;
font-size: var(--student-fs-body-sm);
}
.file-picker {
display: none;
}
.psm-chart-preview {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
margin-top: 14px;
p {
margin: 0 0 7px;
color: var(--student-text);
word-break: break-all;
font-size: var(--student-fs-body-sm);
}
}
.psm-chart-image {
width: 148px;
height: 92px;
overflow: hidden;
border-radius: 8px;
cursor: zoom-in;
}
.psm-guide-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 14px;
margin-top: 14px;
border-top: 1px solid var(--student-panel-border);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.unit-cost-panel,
.workbench-footer,
.value-pricing-toolbar,
.final-pricing-channel,
.psm-toolbar,
.psm-chart-head,
.psm-guide-row,
.task-header {
align-items: stretch;
flex-direction: column;
}
.value-pricing-result {
grid-template-columns: 1fr;
}
.unit-cost-input {
width: 100%;
}
.footer-actions {
justify-content: flex-start;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
width: 100%;
}
.btn-nav {
width: 100%;
}
.toolbar-actions {
justify-content: flex-start;
}
.psm-point-grid {
grid-template-columns: 1fr;
}
}
/* ===== 底部步骤导航按钮 ===== */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
</style>
<style lang="scss">
/* ============================================================
* 教育蓝极简换肤 · product/price.vue
* ============================================================ */
/* 画布背景 */
#app .studentLayout .app-main:has(.price-page) {
background: #ffffff !important;
}
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝极简)
* ============================================================ */
#app .studentLayout .app-main:has(.price-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-connector {
display: none !important;
}
.step-tab {
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: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: nowrap !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,
.step-marker {
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: var(--student-fs-body-sm) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
.step-copy {
margin-left: 0 !important;
gap: 4px !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no,
.step-marker {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no,
.step-marker {
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,
.step-marker {
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,
.step-marker {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮
* ============================================================ */
.price-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: var(--student-fs-body) !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;
}
}
}
/* ============================================================
* 顶部导出按钮(outline-action)
* ============================================================ */
.price-page .workbench-actions>button.outline-action {
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: var(--student-fs-body) !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;
&: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;
}
}
/* ============================================================
* 底部操作按钮(el-button)
* ============================================================ */
.price-page .workbench-footer .el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !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: var(--student-fs-body) !important;
font-weight: 700 !important;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease !important;
}
.price-page .workbench-footer .el-button:hover:not(.is-disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
/* 主按钮(el-button--primary) */
.price-page .workbench-footer .el-button--primary {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
.price-page .workbench-footer .el-button--primary:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
/* ============================================================
* 步骤内按钮(primary-button, secondary-button)
* ============================================================ */
.price-page .primary-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: var(--student-fs-body) !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:not(:disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: translateY(-1px) !important;
}
}
.price-page .secondary-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 #e5e7eb !important;
border-radius: 8px !important;
color: #374151 !important;
background: #f3f4f6 !important;
font-size: var(--student-fs-body) !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:not(:disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
}
/* ============================================================
* 窄屏:横向滑动
* ============================================================ */
@media (max-width: 900px) {
#app .studentLayout .app-main:has(.price-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout .app-main:has(.price-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
</style>