|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="price-page student-training-shell">
|
|
|
|
|
|
<main class="training-core">
|
|
|
|
|
|
<section class="task-header">
|
|
|
|
|
|
<p class="task-kicker">PRODUCT PRICING</p>
|
|
|
|
|
|
<h1>{{ taskTitle }}</h1>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<TrainingTaskBrief
|
|
|
|
|
|
:background="taskBackground"
|
|
|
|
|
|
:goals="taskGoals"
|
|
|
|
|
|
:requirements="taskRequirement"
|
|
|
|
|
|
:show-material="false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<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>{{ currentStep === step.no ? "当前步骤" : "点击切换" }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="step.no < pricingSteps.length"
|
|
|
|
|
|
class="step-connector"
|
|
|
|
|
|
:class="{ complete: currentStep > step.no }"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<header class="workbench-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p class="section-eyebrow">STEP {{ String(currentStep).padStart(2, "0") }}</p>
|
|
|
|
|
|
<h2>{{ currentStepTitle }}</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<TrainingMaterialButton
|
|
|
|
|
|
:material-name="taskConfig?.materialName"
|
|
|
|
|
|
:material-url="taskConfig?.materialUrl"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<el-button type="primary" :loading="saving" @click="saveStepOne">{{ labels.saveStepOne }}</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>
|
|
|
|
|
|
<el-button type="primary" :loading="saving" @click="saveStepThree">{{ labels.saveStepThree }}</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-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>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="psm-chart-section">
|
|
|
|
|
|
<div class="psm-chart-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3>{{ labels.psmChartTitle }}</h3>
|
|
|
|
|
|
<p>{{ labels.psmChartHint }}</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>
|
|
|
|
|
|
<div class="psm-guide-row">
|
|
|
|
|
|
<p>{{ labels.psmGuideTip }}</p>
|
|
|
|
|
|
<button type="button" class="secondary-button" @click="downloadPsmGuide">{{ labels.psmDownloadGuide }}</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="validationMessage" class="validation-message">{{ validationMessage }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="step-footer">
|
|
|
|
|
|
<button type="button" class="primary-button" :disabled="saving" @click="saveStepFour">
|
|
|
|
|
|
{{ saving ? labels.psmSaving : labels.psmSave }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<div class="step-footer">
|
|
|
|
|
|
<button type="button" class="primary-button" :disabled="saving" @click="saveStepFive">
|
|
|
|
|
|
{{ saving ? labels.finalPricingSaving : labels.finalPricingSave }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<el-button type="primary" :loading="saving" @click="saveStepTwo">{{ labels.saveStepTwo }}</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</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 TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.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(价格敏感度测试)通过“太贵不买、太便宜不买、偏高可接受、便宜划算”四类问题衡量用户价格敏感度。请根据案例资料提供的原始数据计算各价格区间累计占比。",
|
|
|
|
|
|
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: "移除图表",
|
|
|
|
|
|
psmGuideTip: "下载案例数据表,使用 Excel 计算累计占比并绘制 PSM 曲线;也可导出本页数据后结合 AI 工具分析。",
|
|
|
|
|
|
psmDownloadGuide: "下载实训操作说明",
|
|
|
|
|
|
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: "\u8bf7\u57fa\u4e8e\u6848\u4f8b\u8d44\u6599\u5b8c\u6210 4 \u4e2a\u7ade\u54c1\u7684\u4ef7\u683c\u53ca\u5dee\u5f02\u5316\u5206\u6790\uff0c\u5e76\u9009\u62e9\u5bf9\u6807\u7ade\u54c1\u5236\u5b9a\u672c\u4ea7\u54c1\u5b9a\u4ef7\u3002",
|
|
|
|
|
|
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 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 pricingSteps = computed(() => [
|
|
|
|
|
|
{ no: 1, title: labels.pricingTitle },
|
|
|
|
|
|
{ no: 2, title: labels.competitorPricingTitle },
|
|
|
|
|
|
{ no: 3, title: labels.valueBasedPricingTitle },
|
|
|
|
|
|
{ no: 4, title: labels.psmTitle },
|
|
|
|
|
|
{ no: 5, title: labels.finalPricingTitle },
|
|
|
|
|
|
]);
|
|
|
|
|
|
const currentStepTitle = computed(() => {
|
|
|
|
|
|
if (currentStep.value === 1) return labels.pricingTitle;
|
|
|
|
|
|
if (currentStep.value === 2) return labels.competitorPricingTitle;
|
|
|
|
|
|
if (currentStep.value === 3) return labels.valueBasedPricingTitle;
|
|
|
|
|
|
if (currentStep.value === 4) return labels.psmTitle;
|
|
|
|
|
|
return labels.finalPricingTitle;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 downloadPsmGuide() {
|
|
|
|
|
|
const content = [
|
|
|
|
|
|
"PSM价格敏感度测试实训操作说明",
|
|
|
|
|
|
"1. 根据案例资料填写六个固定价格区间的四类累计占比:太贵不买、太便宜不买、偏高可接受、便宜划算。",
|
|
|
|
|
|
"2. 可增加自定义价格区间;固定的六个价格区间必须保留。",
|
|
|
|
|
|
"3. 点击“导出”下载当前数据,在 Excel 中绘制 PSM 价格数据折线图。",
|
|
|
|
|
|
"4. 上传绘制完成的折线图,上传后可点击缩略图预览。",
|
|
|
|
|
|
"5. 填写最优价格点、可接受价格点和可接受价格范围后保存。",
|
|
|
|
|
|
].join("\n");
|
|
|
|
|
|
const blob = new Blob([`\ufeff${content}`], { type: "text/plain;charset=utf-8;" });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = url;
|
|
|
|
|
|
link.download = "PSM价格敏感度测试实训操作说明.txt";
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
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;
|
|
|
|
|
|
const message = validateStepFour();
|
|
|
|
|
|
validationMessage.value = message;
|
|
|
|
|
|
if (message) {
|
|
|
|
|
|
proxy?.$modal?.msgWarning?.(message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
} 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: "SAVE",
|
|
|
|
|
|
currentStep: 5,
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveStepOne() {
|
|
|
|
|
|
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: 2,
|
|
|
|
|
|
step1Answer: JSON.stringify(buildStepOneProgress()),
|
|
|
|
|
|
});
|
|
|
|
|
|
hasSavedAnswer.value = true;
|
|
|
|
|
|
currentStep.value = 2;
|
|
|
|
|
|
proxy?.$modal?.msgSuccess?.(labels.stepOneSaveSuccess);
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
const message = validateStepTwo();
|
|
|
|
|
|
validationMessage.value = message;
|
|
|
|
|
|
if (message) {
|
|
|
|
|
|
proxy?.$modal?.msgWarning?.(message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
const message = validateStepThree();
|
|
|
|
|
|
validationMessage.value = message;
|
|
|
|
|
|
if (message) {
|
|
|
|
|
|
proxy?.$modal?.msgWarning?.(message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
} 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 {
|
|
|
|
|
|
--shell-bg: #061923;
|
|
|
|
|
|
--surface: rgba(8, 35, 48, 0.9);
|
|
|
|
|
|
--surface-soft: rgba(13, 51, 67, 0.7);
|
|
|
|
|
|
--line: rgba(91, 197, 231, 0.25);
|
|
|
|
|
|
--line-strong: rgba(87, 211, 247, 0.55);
|
|
|
|
|
|
--ink: #edfaff;
|
|
|
|
|
|
--muted: #a3c4d0;
|
|
|
|
|
|
--accent: #24c6f2;
|
|
|
|
|
|
--accent-deep: #1184bb;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) 330px;
|
|
|
|
|
|
gap: 24px;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
padding: 26px;
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at 10% 0%, rgba(24, 164, 213, 0.18), transparent 31rem),
|
|
|
|
|
|
linear-gradient(135deg, #061923 0%, #082633 46%, #061923 100%);
|
|
|
|
|
|
|
|
|
|
|
|
.training-core {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.task-header {
|
|
|
|
|
|
margin-bottom: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
.task-kicker,
|
|
|
|
|
|
h1 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.task-kicker,
|
|
|
|
|
|
.section-eyebrow {
|
|
|
|
|
|
color: #66d9fb;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
letter-spacing: 0.16em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
font-size: 30px;
|
|
|
|
|
|
line-height: 1.25;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.price-workbench {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
margin-top: 18px;
|
|
|
|
|
|
border: 1px solid var(--line-strong);
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-indicator {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
width: calc(100% - 48px);
|
|
|
|
|
|
margin: 18px 24px 0;
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
border: 1px solid rgba(0, 180, 255, 0.3);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: linear-gradient(110deg, rgba(4, 39, 58, 0.9), rgba(2, 20, 33, 0.94));
|
|
|
|
|
|
box-shadow: inset 0 1px 0 rgba(169, 229, 255, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-tab {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 1 0 136px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
min-height: 52px;
|
|
|
|
|
|
padding: 7px 9px;
|
|
|
|
|
|
color: #9ab3d0;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
border: 1px solid rgba(68, 141, 177, 0.35);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
background: rgba(3, 27, 43, 0.52);
|
|
|
|
|
|
transition: transform 0.2s, color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover,
|
|
|
|
|
|
&:focus-visible {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: rgba(89, 223, 255, 0.82);
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
background: rgba(8, 92, 131, 0.52);
|
|
|
|
|
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.2);
|
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: rgba(89, 226, 255, 0.85);
|
|
|
|
|
|
background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64));
|
|
|
|
|
|
box-shadow: 0 0 18px rgba(24, 183, 235, 0.22), inset 0 1px 0 rgba(176, 246, 255, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.complete {
|
|
|
|
|
|
color: #dffbff;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-marker {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
flex: 0 0 30px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 30px;
|
|
|
|
|
|
height: 30px;
|
|
|
|
|
|
color: #a6c4d9;
|
|
|
|
|
|
border: 1px solid rgba(101, 176, 208, 0.6);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: rgba(3, 25, 40, 0.8);
|
|
|
|
|
|
box-shadow: inset 0 0 12px rgba(38, 174, 233, 0.08);
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-copy {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
gap: 3px;
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
strong {
|
|
|
|
|
|
display: -webkit-box;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.25;
|
|
|
|
|
|
-webkit-box-orient: vertical;
|
|
|
|
|
|
-webkit-line-clamp: 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
|
color: #66b6d4;
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-tab.active .step-marker {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: #72e8ff;
|
|
|
|
|
|
background: linear-gradient(135deg, #08b7e8, #1375d0);
|
|
|
|
|
|
box-shadow: 0 0 0 5px rgba(25, 179, 237, 0.14), 0 0 18px rgba(46, 208, 255, 0.58);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-tab.complete .step-marker {
|
|
|
|
|
|
color: #062b37;
|
|
|
|
|
|
border-color: rgba(84, 230, 221, 0.85);
|
|
|
|
|
|
background: #6ef0e1;
|
|
|
|
|
|
box-shadow: 0 0 12px rgba(79, 231, 218, 0.35);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-connector {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
flex: 0 1 40px;
|
|
|
|
|
|
min-width: 18px;
|
|
|
|
|
|
height: 2px;
|
|
|
|
|
|
margin: 0 8px;
|
|
|
|
|
|
color: transparent;
|
|
|
|
|
|
background: rgba(98, 169, 201, 0.34);
|
|
|
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
right: -1px;
|
|
|
|
|
|
width: 6px;
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
border-top: 1px solid #73b1ce;
|
|
|
|
|
|
border-right: 1px solid #73b1ce;
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
transform: translateY(-50%) rotate(45deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.complete {
|
|
|
|
|
|
background: linear-gradient(90deg, #6ef0e1, #1cb8e9);
|
|
|
|
|
|
box-shadow: 0 0 9px rgba(53, 218, 235, 0.46);
|
|
|
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
|
border-color: #63e6f1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.primary-button,
|
|
|
|
|
|
.secondary-button {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
|
padding: 0 14px;
|
|
|
|
|
|
color: #e9fbff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
border: 1px solid rgba(99, 217, 255, 0.55);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
transition: transform 0.18s, border-color 0.18s, background 0.18s, opacity 0.18s;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover:not(:disabled),
|
|
|
|
|
|
&:focus-visible:not(:disabled) {
|
|
|
|
|
|
border-color: #74ebff;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:disabled {
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
opacity: 0.45;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.primary-button {
|
|
|
|
|
|
background: linear-gradient(135deg, #08a5d6, #147bb9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.secondary-button {
|
|
|
|
|
|
background: rgba(17, 126, 178, 0.36);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.toolbar-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.workbench-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
padding: 24px 26px 18px;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
|
margin: 4px 0 0;
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.intro-copy {
|
|
|
|
|
|
margin: 20px 26px;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
line-height: 1.75;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.unit-cost-panel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
margin: 0 26px 22px;
|
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: var(--surface-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cost-panel-copy {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
margin: 0 0 4px;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cost-dot {
|
|
|
|
|
|
width: 10px;
|
|
|
|
|
|
height: 10px;
|
|
|
|
|
|
margin-top: 7px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
box-shadow: 0 0 12px var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.unit-cost-input {
|
|
|
|
|
|
width: 270px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pricing-table-wrap {
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
margin: 0 26px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pricing-table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-width: 720px;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
|
td {
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
th {
|
|
|
|
|
|
color: #ddf8ff;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
background: rgba(25, 122, 157, 0.46);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody tr:last-child td {
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
th:first-child,
|
|
|
|
|
|
td:first-child {
|
|
|
|
|
|
width: 84px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.competitor-section {
|
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.subsection-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin: 0 26px 12px;
|
|
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
width: 22px;
|
|
|
|
|
|
height: 22px;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
color: #7de2ff;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
background: rgba(117, 226, 255, 0.18);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h3 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.competitor-table {
|
|
|
|
|
|
min-width: 900px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.decision-table {
|
|
|
|
|
|
min-width: 1160px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.competitor-table th:first-child,
|
|
|
|
|
|
.competitor-table td:first-child,
|
|
|
|
|
|
.decision-table th:first-child,
|
|
|
|
|
|
.decision-table td:first-child {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.competitor-table td,
|
|
|
|
|
|
.decision-table td {
|
|
|
|
|
|
vertical-align: top;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.serial-cell {
|
|
|
|
|
|
color: #7de2ff;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.channel-cell {
|
|
|
|
|
|
color: #f2fcff;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markup-input {
|
|
|
|
|
|
width: 190px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.range-hint {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sale-price-cell {
|
|
|
|
|
|
color: #7de2ff;
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-price {
|
|
|
|
|
|
color: #7998a3;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.validation-message {
|
|
|
|
|
|
margin: 14px 26px 0;
|
|
|
|
|
|
color: #ff9e9e;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value-pricing-section {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value-pricing-toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
max-width: 760px;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perceived-value-table {
|
|
|
|
|
|
min-width: 620px;
|
|
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
|
td {
|
|
|
|
|
|
width: 50%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.total-row td {
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
background: rgba(26, 167, 211, 0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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: 20px 24px;
|
|
|
|
|
|
background: rgba(5, 35, 48, 0.72);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
label {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value-price-output {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 7px;
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
strong {
|
|
|
|
|
|
color: #7de2ff;
|
|
|
|
|
|
font-size: 23px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
em {
|
|
|
|
|
|
color: #7998a3;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.workbench-footer {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
margin-top: 22px;
|
|
|
|
|
|
padding: 18px 26px;
|
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.42);
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: none;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-input__wrapper) {
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.7);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(94, 213, 245, 0.3) inset;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-input__inner) {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-textarea__inner) {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.7);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(94, 213, 245, 0.3) inset;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-input-group__append) {
|
|
|
|
|
|
color: #91dff5;
|
|
|
|
|
|
border-left-color: rgba(94, 213, 245, 0.3);
|
|
|
|
|
|
background: rgba(16, 98, 130, 0.4);
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.final-pricing-section {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 22px;
|
|
|
|
|
|
padding-bottom: 26px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.final-pricing-intro {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.final-pricing-channel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
margin: 0 26px;
|
|
|
|
|
|
padding: 16px 18px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: var(--surface-soft);
|
|
|
|
|
|
|
|
|
|
|
|
h3,
|
|
|
|
|
|
p {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h3 {
|
|
|
|
|
|
color: #e5f9ff;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-select) {
|
|
|
|
|
|
width: min(360px, 100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.final-pricing-table {
|
|
|
|
|
|
min-width: 680px;
|
|
|
|
|
|
|
|
|
|
|
|
th:first-child,
|
|
|
|
|
|
td:first-child {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
td:nth-child(2),
|
|
|
|
|
|
td:nth-child(3) {
|
|
|
|
|
|
color: #7de2ff;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.final-analysis-section {
|
|
|
|
|
|
margin: 0 26px;
|
|
|
|
|
|
|
|
|
|
|
|
h3 {
|
|
|
|
|
|
margin: 0 0 12px;
|
|
|
|
|
|
color: #e5f9ff;
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1440px) {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
|
padding: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
.workbench-head,
|
|
|
|
|
|
.unit-cost-panel,
|
|
|
|
|
|
.workbench-footer,
|
|
|
|
|
|
.value-pricing-toolbar,
|
|
|
|
|
|
.final-pricing-channel {
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value-pricing-result {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.unit-cost-input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-actions {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
/* PSM dark theme */
|
|
|
|
|
|
.price-page {
|
|
|
|
|
|
.psm-section {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
padding: 0 26px 26px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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(--muted);
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-subtitle {
|
|
|
|
|
|
margin-top: 8px !important;
|
|
|
|
|
|
color: #dffaff !important;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table-scroll {
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.42);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-width: 980px;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
|
td {
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
th {
|
|
|
|
|
|
color: #ddf8ff;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
background: rgba(25, 122, 157, 0.46);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
td {
|
|
|
|
|
|
min-width: 150px;
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
td:first-child {
|
|
|
|
|
|
min-width: 120px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody tr:last-child td {
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-table input,
|
|
|
|
|
|
.psm-point-grid input {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-height: 38px;
|
|
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
|
color: #edfaff;
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.7);
|
|
|
|
|
|
border: 1px solid rgba(94, 213, 245, 0.3);
|
|
|
|
|
|
border-radius: 7px;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
|
|
|
|
|
|
&:focus {
|
|
|
|
|
|
border-color: #63e5ff;
|
|
|
|
|
|
box-shadow: 0 0 0 3px rgba(50, 204, 241, 0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-point-section,
|
|
|
|
|
|
.psm-chart-section {
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
background: rgba(5, 35, 48, 0.72);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-point-section h3,
|
|
|
|
|
|
.psm-chart-section h3 {
|
|
|
|
|
|
margin: 0 0 14px;
|
|
|
|
|
|
color: #e5f9ff;
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-point-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-point-grid label {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-picker {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-chart-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
background: rgba(2, 19, 28, 0.7);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-chart-image {
|
|
|
|
|
|
width: 148px;
|
|
|
|
|
|
height: 92px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
cursor: zoom-in;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-chart-preview p {
|
|
|
|
|
|
margin: 0 0 7px;
|
|
|
|
|
|
color: #dffaff;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-guide-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
padding-top: 16px;
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.step-footer {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.text-button {
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
color: #63e5ff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.psm-section {
|
|
|
|
|
|
padding: 0 14px 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-toolbar,
|
|
|
|
|
|
.psm-chart-head,
|
|
|
|
|
|
.psm-guide-row {
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.toolbar-actions {
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.psm-point-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|