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

2426 lines
77 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="price-page student-training-shell">
<main class="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">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
/>
<button type="button" class="outline-action" @click="exportPriceOutcome">
<el-icon><Download /></el-icon>
</button>
</div>
</div>
<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>
<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>
<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>
</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 { Download } from "@element-plus/icons-vue";
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 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 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 {
margin-top: 28px;
padding: 18px 22px 22px;
border: 1px solid rgba(82, 174, 226, 0.42);
border-radius: 26px;
background: rgba(0, 25, 42, 0.64);
}
.workbench-head--actions-only {
display: flex;
justify-content: flex-end;
margin-bottom: 18px;
}
.workbench-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 18px;
color: #e9fbff;
cursor: pointer;
border: 1px solid rgba(99, 217, 255, 0.55);
border-radius: 999px;
background: rgba(17, 126, 178, 0.36);
font: inherit;
font-size: 14px;
font-weight: 800;
&:hover,
&:focus-visible {
border-color: rgba(116, 235, 255, 0.85);
outline: none;
background: rgba(18, 146, 204, 0.5);
transform: translateY(-1px);
}
}
.step-indicator {
display: flex;
align-items: center;
gap: 0;
width: calc(100% + 24px);
margin: 0 -12px 32px;
padding: 18px 22px;
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 145px;
align-items: center;
min-width: 0;
min-height: 66px;
padding: 8px 10px;
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 38px;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
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: 4px;
margin-left: 10px;
strong {
display: -webkit-box;
overflow: hidden;
color: inherit;
font-size: 14px;
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;
}
.step-card {
padding: 18px 22px 22px;
border: 1px solid rgba(82, 174, 226, 0.42);
border-radius: 26px;
background: rgba(0, 25, 42, 0.64);
}
.step-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h3 {
margin: 4px 0 0;
color: #fff;
font-size: 22px;
font-weight: 900;
line-height: 1.3;
}
}
.intro-copy {
margin: 20px 0;
color: var(--muted);
line-height: 1.75;
}
.unit-cost-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 0 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;
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 0 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 0 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;
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;
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;
}
.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>