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

1445 lines
44 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">
<!-- legacy malformed navigation retained only for source compatibility
<nav class="step-switcher" aria-label="">
<button type="button" :class="{ active: currentStep === 1 }" @click="switchStep(1)">
<span>01</span>{{ labels.pricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 2 }" @click="switchStep(2)">
<span>02</span>{{ labels.competitorPricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 4 }" @click="switchStep(4)">
<span>04</span>{{ labels.psmTitle }}
</button>
</nav>
-->
<nav class="step-switcher" aria-label="">
<button type="button" :class="{ active: currentStep === 1 }" @click="switchStep(1)">
<span>01</span>{{ labels.pricingTitle }}
</button>
<i></i>
<button type="button" :class="{ active: currentStep === 2 }" @click="switchStep(2)">
<span>02</span>{{ labels.competitorPricingTitle }}
</button>
</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 === 4">
<section class="psm-section">
<div class="psm-toolbar">
<p>{{ labels.psmIntro }}</p>
<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" /></td>
<td><input v-model="row.tooCheap" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
<td><input v-model="row.expensiveAcceptable" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></td>
<td><input v-model="row.cheapValue" type="number" min="0" max="100" step="0.01" :placeholder="labels.psmPercentagePlaceholder" /></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="text" :placeholder="labels.psmPricePointPlaceholder" />
</label>
<label>
<span>{{ labels.psmAcceptablePrice }}</span>
<input v-model="psmPricePoints.acceptablePrice" type="text" :placeholder="labels.psmPricePointPlaceholder" />
</label>
<label>
<span>{{ labels.psmAcceptableRange }}</span>
<input v-model="psmPricePoints.acceptableRange" type="text" :placeholder="labels.psmPricePointPlaceholder" />
</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>
</section>
<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>
<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-input v-model="pricingDecision.benchmarkCompetitor" :placeholder="labels.benchmarkPlaceholder" @input="clearValidation" /></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,
} 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 累计占比,并补充价格点结论。",
psmAddRow: "增加一行",
psmRemoveRow: "删除一行",
psmExport: "导出",
psmPriceRange: "价格(元)",
psmTooExpensive: "太贵不买累计占比(%",
psmTooCheap: "太便宜不买累计占比(%",
psmExpensiveAcceptable: "偏高可接受累计占比(%",
psmCheapValue: "便宜划算累计占比(%",
psmCustomRangePlaceholder: "填写价格区间",
psmPercentagePlaceholder: "0-100",
psmPricePointTitle: "确定价格点",
psmOptimalPrice: "最优价格点",
psmAcceptablePrice: "可接受价格点",
psmAcceptableRange: "可接受价格范围",
psmPricePointPlaceholder: "学生手动填写",
psmChartTitle: "PSM价格数据可视化",
psmChartHint: "上传绘制完成的 PSM 价格数据折线图,支持点击缩略图预览。",
psmUploadChart: "上传PSM价格数据折线图",
psmChartUploadSuccess: "图表上传成功",
psmChartUploadError: "图表上传失败,请重试",
psmChartRemove: "移除图表",
psmSave: "保存PSM价格敏感度测试",
psmSaving: "保存中...",
psmSaveSuccess: "PSM价格敏感度测试已保存",
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\u8bf7\u4f9d\u636e\u6848\u4f8b\u8d44\u6599\uff0c\u4e3a\u4e0d\u540c\u6e20\u9053\u586b\u5199\u5408\u9002\u7684\u52a0\u6210\u7387\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: "\u5efa\u8bae\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\u586b\u5199\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",
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_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 canRemovePsmRow = computed(() => psmRows.value.some((row) => !row.isDefault));
const currentStepTitle = computed(() => {
if (currentStep.value === 1) return labels.pricingTitle;
if (currentStep.value === 2) return labels.competitorPricingTitle;
return labels.psmTitle;
});
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?.taskRequirement ||
"\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?.trainingGoal, [
"\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;
});
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: "",
differentiator: "",
pricingReason: "",
};
}
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 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);
}
async function saveStepFour() {
if (saving.value) return;
saving.value = true;
try {
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 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 isBlank(value) {
return !String(value ?? "").trim();
}
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 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 {
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 {
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 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 stepFourProgress = parseSavedJson(answer?.step4Answer);
hasSavedAnswer.value = Boolean(stepOneProgress || stepTwoProgress || stepFourProgress);
if (stepOneProgress) {
applyStepOneProgress(stepOneProgress);
}
if (stepTwoProgress) {
applyStepTwoProgress(stepTwoProgress);
currentStep.value = 2;
}
if (stepFourProgress) {
applyStepFourProgress(stepFourProgress);
currentStep.value = 4;
}
} 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-switcher {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 18px 26px 0;
button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: var(--muted);
cursor: pointer;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
}
button.active {
color: #e9fbff;
border-color: var(--line-strong);
background: rgba(20, 149, 190, 0.25);
}
span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
color: #7de2ff;
font-size: 11px;
font-weight: 700;
background: rgba(117, 226, 255, 0.18);
border-radius: 50%;
}
i {
color: #55d3f8;
font-size: 18px;
font-style: normal;
}
}
.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;
}
.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;
}
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
@media (max-width: 640px) {
padding: 14px;
.workbench-head,
.unit-cost-panel,
.workbench-footer {
align-items: stretch;
flex-direction: column;
}
.unit-cost-input {
width: 100%;
}
.footer-actions {
justify-content: flex-end;
}
}
}
.psm-section {
display: grid;
gap: 20px;
}
.psm-toolbar,
.psm-chart-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.psm-toolbar p,
.psm-chart-head p {
margin: 0;
color: #5e6b7b;
line-height: 1.7;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.psm-table input,
.psm-point-grid input {
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
color: #243447;
background: #fff;
border: 1px solid #d8e1eb;
border-radius: 6px;
}
.psm-table td {
min-width: 170px;
}
.psm-point-section,
.psm-chart-section {
padding: 20px;
background: #fbfdff;
border: 1px solid #dbe5ef;
border-radius: 12px;
}
.psm-point-section h3,
.psm-chart-section h3 {
margin: 0 0 14px;
color: #20344b;
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: #45566a;
font-weight: 600;
}
.file-picker {
display: none;
}
.psm-chart-preview {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
margin-top: 16px;
background: #fff;
border: 1px solid #dbe5ef;
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: #45566a;
word-break: break-all;
}
.text-button {
padding: 0;
color: #1687c6;
cursor: pointer;
background: transparent;
border: 0;
}
@media (max-width: 768px) {
.psm-toolbar,
.psm-chart-head {
flex-direction: column;
}
.toolbar-actions {
justify-content: flex-start;
}
.psm-point-grid {
grid-template-columns: 1fr;
}
}
</style>