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

4 months ago
<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>
4 months ago
</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()])
})
4 months ago
</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>